""" Main window for Serien-Checker application """ from PyQt5.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QListWidget, QTableWidget, QTableWidgetItem, QLabel, QPushButton, QStatusBar, QMessageBox, QMenu, QAction, QHeaderView, QGroupBox ) from PyQt5.QtCore import Qt, QUrl from PyQt5.QtGui import QDesktopServices, QColor, QIcon from datetime import datetime from typing import Optional from ..database.db_manager import DatabaseManager from ..database.models import Series, Season, Episode, DatePreference from ..utils.logger import setup_logger from ..utils.threading import UpdateWorker from .options_dialog import OptionsDialog logger = setup_logger() class MainWindow(QMainWindow): """Main application window with 3-column layout""" def __init__(self, db_manager: DatabaseManager): super().__init__() self.db = db_manager self.current_series: Optional[Series] = None self.current_season: Optional[Season] = None self.worker = None # Keep reference to worker thread self.init_ui() self.load_series_list() def init_ui(self): """Initialize user interface""" self.setWindowTitle("Serien-Checker") self.setGeometry(100, 100, 1400, 800) # Set window icon if available try: import sys from pathlib import Path if getattr(sys, 'frozen', False): # Running as EXE - PyInstaller extracts to _MEIPASS if hasattr(sys, '_MEIPASS'): icon_path = Path(sys._MEIPASS) / "icon.ico" else: icon_path = Path(sys.executable).parent / "icon.ico" else: # Running as script icon_path = Path(__file__).parent.parent.parent / "icon.ico" if icon_path.exists(): self.setWindowIcon(QIcon(str(icon_path))) except Exception: pass # Central widget central_widget = QWidget() self.setCentralWidget(central_widget) # Main layout main_layout = QVBoxLayout(central_widget) # Create splitter for 3-column layout splitter = QSplitter(Qt.Horizontal) # Left column: Series list self.series_list_widget = self._create_series_list() splitter.addWidget(self.series_list_widget) # Middle column: Episode list self.episode_list_widget = self._create_episode_list() splitter.addWidget(self.episode_list_widget) # Right column: Series info self.info_widget = self._create_info_panel() splitter.addWidget(self.info_widget) # Set initial splitter sizes (30% - 40% - 30%) splitter.setSizes([400, 600, 400]) main_layout.addWidget(splitter) # Status bar self.status_bar = QStatusBar() self.setStatusBar(self.status_bar) self.status_label = QLabel("Bereit") self.last_update_label = QLabel("Letztes Update: Nie") self.new_episodes_label = QLabel("Neue Folgen: 0") self.status_bar.addWidget(self.status_label) self.status_bar.addPermanentWidget(self.new_episodes_label) self.status_bar.addPermanentWidget(self.last_update_label) # Menu bar self._create_menu_bar() def _create_series_list(self) -> QWidget: """Create series list widget""" widget = QWidget() layout = QVBoxLayout(widget) # Title title = QLabel("Serien") title.setStyleSheet("font-weight: bold; font-size: 14px;") layout.addWidget(title) # List widget self.series_list = QListWidget() self.series_list.currentItemChanged.connect(self.on_series_selected) self.series_list.setContextMenuPolicy(Qt.CustomContextMenu) self.series_list.customContextMenuRequested.connect(self.show_series_context_menu) layout.addWidget(self.series_list) # Add series button add_btn = QPushButton("+ Serie hinzufügen") add_btn.clicked.connect(self.add_series) layout.addWidget(add_btn) return widget def _create_episode_list(self) -> QWidget: """Create episode list widget""" widget = QWidget() layout = QVBoxLayout(widget) # Title title = QLabel("Folgen") title.setStyleSheet("font-weight: bold; font-size: 14px;") layout.addWidget(title) # Table widget self.episode_table = QTableWidget() self.episode_table.setColumnCount(5) self.episode_table.setHorizontalHeaderLabels(["Nr.", "Staffel", "Folge", "Titel", "Datum"]) self.episode_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.Stretch) # Titel column stretches self.episode_table.setSelectionBehavior(QTableWidget.SelectRows) self.episode_table.setEditTriggers(QTableWidget.NoEditTriggers) self.episode_table.verticalHeader().setVisible(False) # Hide row numbers (1, 2, 3, 4...) layout.addWidget(self.episode_table) return widget def _create_info_panel(self) -> QWidget: """Create series info panel""" widget = QWidget() layout = QVBoxLayout(widget) # Title title = QLabel("Informationen") title.setStyleSheet("font-weight: bold; font-size: 14px;") layout.addWidget(title) # Info group info_group = QGroupBox("Serie") info_layout = QVBoxLayout(info_group) self.info_title = QLabel("Keine Serie ausgewählt") self.info_title.setWordWrap(True) self.info_title.setStyleSheet("font-weight: bold;") info_layout.addWidget(self.info_title) self.info_seasons = QLabel("") info_layout.addWidget(self.info_seasons) self.info_episodes = QLabel("") info_layout.addWidget(self.info_episodes) self.info_link = QLabel("") self.info_link.setOpenExternalLinks(True) self.info_link.setTextFormat(Qt.RichText) self.info_link.setTextInteractionFlags(Qt.TextBrowserInteraction) info_layout.addWidget(self.info_link) info_layout.addStretch() layout.addWidget(info_group) # Seasons group seasons_group = QGroupBox("Staffeln") seasons_layout = QVBoxLayout(seasons_group) self.seasons_list = QListWidget() self.seasons_list.currentItemChanged.connect(self.on_season_selected) seasons_layout.addWidget(self.seasons_list) layout.addWidget(seasons_group) return widget def _create_menu_bar(self): """Create menu bar""" menubar = self.menuBar() # File menu file_menu = menubar.addMenu("Datei") add_action = QAction("Serie hinzufügen", self) add_action.triggered.connect(self.add_series) file_menu.addAction(add_action) file_menu.addSeparator() exit_action = QAction("Beenden", self) exit_action.triggered.connect(self.close) file_menu.addAction(exit_action) # Series menu series_menu = menubar.addMenu("Serien") update_action = QAction("Aktualisieren", self) update_action.triggered.connect(self.update_current_series) series_menu.addAction(update_action) update_all_action = QAction("Alle aktualisieren", self) update_all_action.triggered.connect(self.update_all_series) series_menu.addAction(update_all_action) # Settings menu settings_menu = menubar.addMenu("Einstellungen") options_action = QAction("Optionen", self) options_action.triggered.connect(self.show_options) settings_menu.addAction(options_action) # Help menu help_menu = menubar.addMenu("Hilfe") about_action = QAction("Über", self) about_action.triggered.connect(self.show_about) help_menu.addAction(about_action) def load_series_list(self): """Load all series into the list""" self.series_list.clear() series_list = self.db.get_all_series() for series in series_list: # Check if series has future episodes has_new = self.db.has_future_episodes(series.id) title = f"⭐️ {series.title}" if has_new else series.title self.series_list.addItem(title) self.status_label.setText(f"{len(series_list)} Serien geladen") def on_series_selected(self, current, previous): """Handle series selection""" if not current: return series_title = current.text() # Remove star emoji if present series_title = series_title.replace("⭐️ ", "") series_list = self.db.get_all_series() self.current_series = next((s for s in series_list if s.title == series_title), None) if self.current_series: self.load_series_info() self.load_seasons_list() # Show recent episodes automatically self.load_recent_episodes() def load_series_info(self): """Load series information panel""" if not self.current_series: return self.info_title.setText(self.current_series.title) seasons = self.db.get_seasons_by_series(self.current_series.id) self.info_seasons.setText(f"Staffeln: {len(seasons)}") episode_count = self.db.get_episode_count(self.current_series.id) self.info_episodes.setText(f"Folgen: {episode_count}") self.info_link.setText(f'fernsehserien.de öffnen') # Update last update time if self.current_series.last_updated: last_update_str = self.current_series.last_updated.strftime("%d.%m.%Y %H:%M") self.last_update_label.setText(f"Letztes Update: {last_update_str}") def load_seasons_list(self): """Load seasons list""" if not self.current_series: return self.seasons_list.clear() # Add "Neuste Folgen" as first item self.seasons_list.addItem("Neuste Folgen") # Add regular seasons seasons = self.db.get_seasons_by_series(self.current_series.id) for season in seasons: self.seasons_list.addItem(season.name) # Auto-select "Neuste Folgen" by default self.seasons_list.setCurrentRow(0) def on_season_selected(self, current, previous): """Handle season selection""" if not current or not self.current_series: return season_name = current.text() # Check if "Neuste Folgen" was selected if season_name == "Neuste Folgen": self.current_season = None # No specific season self.load_recent_episodes() return # Regular season selection seasons = self.db.get_seasons_by_series(self.current_series.id) self.current_season = next((s for s in seasons if s.name == season_name), None) if self.current_season: self.load_episodes() def load_recent_episodes(self): """Load 20 most recent episodes for current series""" if not self.current_series: return recent_episodes = self.db.get_recent_episodes(self.current_series.id, limit=20) self.episode_table.setRowCount(len(recent_episodes)) now = datetime.now() for row, (season, episode) in enumerate(recent_episodes): # Nr. (fortlaufende Gesamtnummer) nr_text = str(episode.episode_number) if episode.episode_number else "—" nr_item = QTableWidgetItem(nr_text) self.episode_table.setItem(row, 0, nr_item) # Staffel season_item = QTableWidgetItem(season.name) self.episode_table.setItem(row, 1, season_item) # Folge (Episodennummer der Staffel) episode_item = QTableWidgetItem(episode.episode_code) self.episode_table.setItem(row, 2, episode_item) # Title title_item = QTableWidgetItem(episode.title) self.episode_table.setItem(row, 3, title_item) # Date date_str = episode.comparison_date.strftime("%d.%m.%Y") if episode.comparison_date else "—" date_item = QTableWidgetItem(date_str) self.episode_table.setItem(row, 4, date_item) # Highlight future episodes in green highlight_enabled = self.db.get_setting("highlight_future", "true") == "true" if highlight_enabled and episode.comparison_date and episode.comparison_date > now: green = QColor(200, 255, 200) nr_item.setBackground(green) season_item.setBackground(green) episode_item.setBackground(green) title_item.setBackground(green) date_item.setBackground(green) def load_episodes(self): """Load episodes for current season""" if not self.current_season: return episodes = self.db.get_episodes_by_season(self.current_season.id) self.episode_table.setRowCount(len(episodes)) now = datetime.now() for row, episode in enumerate(episodes): # Nr. (episode_number from fernsehserien.de) nr_text = str(episode.episode_number) if episode.episode_number else "—" nr_item = QTableWidgetItem(nr_text) self.episode_table.setItem(row, 0, nr_item) # Staffel (current season name) season_item = QTableWidgetItem(self.current_season.name) self.episode_table.setItem(row, 1, season_item) # Folge (episode code) episode_item = QTableWidgetItem(episode.episode_code) self.episode_table.setItem(row, 2, episode_item) # Title title_item = QTableWidgetItem(episode.title) self.episode_table.setItem(row, 3, title_item) # Date date_str = episode.comparison_date.strftime("%d.%m.%Y") if episode.comparison_date else "—" date_item = QTableWidgetItem(date_str) self.episode_table.setItem(row, 4, date_item) # Highlight future episodes in green highlight_enabled = self.db.get_setting("highlight_future", "true") == "true" if highlight_enabled and episode.comparison_date and episode.comparison_date > now: green = QColor(200, 255, 200) nr_item.setBackground(green) season_item.setBackground(green) episode_item.setBackground(green) title_item.setBackground(green) date_item.setBackground(green) def show_series_context_menu(self, position): """Show context menu for series list""" menu = QMenu() update_action = QAction("Aktualisieren", self) update_action.triggered.connect(self.update_current_series) menu.addAction(update_action) settings_action = QAction("Einstellungen", self) settings_action.triggered.connect(self.show_series_settings) menu.addAction(settings_action) remove_action = QAction("Entfernen", self) remove_action.triggered.connect(self.remove_current_series) menu.addAction(remove_action) menu.exec_(self.series_list.mapToGlobal(position)) def add_series(self): """Add a new series""" from .options_dialog import OptionsDialog dialog = OptionsDialog(self, self.db) if dialog.exec_(): self.load_series_list() def update_current_series(self): """Update currently selected series""" if not self.current_series: QMessageBox.warning(self, "Warnung", "Bitte wählen Sie zuerst eine Serie aus") return from ..scraper.browser_scraper import BrowserScraper, SeriesUpdater from ..utils.threading import UpdateWorker from .widgets import ProgressDialog, UpdateResultDialog # Create progress dialog progress = ProgressDialog(self, "Serie aktualisieren") progress.set_determinate() # Use determinate mode for progress bar progress.show() # Create worker scraper = BrowserScraper() def do_update(): # Progress callback that will be called from the worker thread def progress_callback(percent, message): self.worker.signals.progress.emit(percent, message) updater = SeriesUpdater(self.db, scraper, progress_callback=progress_callback) return updater.update_series(self.current_series.id) self.worker = UpdateWorker(do_update) def on_progress(percent, message): progress.set_progress(percent, message) def on_finished(stats): progress.close() UpdateResultDialog(self, stats).exec_() # Reload UI self.load_series_info() self.load_seasons_list() if self.current_season: self.load_episodes() self.worker = None # Clear worker reference def on_error(error, traceback): progress.close() QMessageBox.critical(self, "Fehler", f"Update fehlgeschlagen:\n{error}") self.worker = None # Clear worker reference self.worker.signals.progress.connect(on_progress) self.worker.signals.finished.connect(on_finished) self.worker.signals.error.connect(on_error) self.worker.start() def update_all_series(self): """Update all series""" all_series = self.db.get_all_series() if not all_series: QMessageBox.information(self, "Info", "Keine Serien vorhanden") return reply = QMessageBox.question( self, "Alle Serien aktualisieren", f"Möchten Sie alle {len(all_series)} Serien aktualisieren?\nDies kann einige Minuten dauern.", QMessageBox.Yes | QMessageBox.No ) if reply != QMessageBox.Yes: return from ..scraper.browser_scraper import BrowserScraper, SeriesUpdater from ..utils.threading import UpdateWorker from .widgets import ProgressDialog, UpdateResultDialog # Create progress dialog progress = ProgressDialog(self, "Alle Serien aktualisieren") progress.set_determinate() progress.show() # Create worker scraper = BrowserScraper() def do_update_all(): total_stats = { 'new_seasons': 0, 'new_episodes': 0, 'updated_episodes': 0, 'unchanged': 0 } total_series = len(all_series) for idx, series in enumerate(all_series): try: # Progress callback for individual series def progress_callback(percent, message): # Calculate overall progress: each series gets an equal portion overall_percent = int((idx / total_series) * 100 + (percent / total_series)) overall_message = f"[{idx + 1}/{total_series}] {series.title}: {message}" self.worker.signals.progress.emit(overall_percent, overall_message) updater = SeriesUpdater(self.db, scraper, progress_callback=progress_callback) stats = updater.update_series(series.id) for key in total_stats: total_stats[key] += stats.get(key, 0) except Exception as e: logger.error(f"Error updating {series.title}: {e}") return total_stats self.worker = UpdateWorker(do_update_all) def on_progress(percent, message): progress.set_progress(percent, message) def on_finished(stats): progress.close() UpdateResultDialog(self, stats).exec_() # Reload UI self.load_series_list() if self.current_series: self.load_series_info() self.load_seasons_list() if self.current_season: self.load_episodes() self.worker = None # Clear worker reference def on_error(error, traceback): progress.close() QMessageBox.critical(self, "Fehler", f"Update fehlgeschlagen:\n{error}") self.worker = None # Clear worker reference self.worker.signals.progress.connect(on_progress) self.worker.signals.finished.connect(on_finished) self.worker.signals.error.connect(on_error) self.worker.start() def remove_current_series(self): """Remove currently selected series""" if not self.current_series: return reply = QMessageBox.question( self, "Serie entfernen", f"Möchten Sie '{self.current_series.title}' wirklich entfernen?", QMessageBox.Yes | QMessageBox.No ) if reply == QMessageBox.Yes: self.db.delete_series(self.current_series.id) self.current_series = None self.current_season = None self.load_series_list() self.episode_table.setRowCount(0) self.seasons_list.clear() self.info_title.setText("Keine Serie ausgewählt") def show_series_settings(self): """Show settings for current series""" QMessageBox.information(self, "Hinweis", "Einstellungen werden noch implementiert") def show_options(self): """Show options dialog""" dialog = OptionsDialog(self, self.db) if dialog.exec_(): self.load_series_list() def show_about(self): """Show about dialog""" QMessageBox.about( self, "Über Serien-Checker", "Serien-Checker v2.0\n\n" "TV-Serien Episode Tracker\n" "Datenquelle: fernsehserien.de" ) def closeEvent(self, event): """Handle window close event""" # Wait for worker thread to finish if running if self.worker and self.worker.isRunning(): logger.info("Waiting for worker thread to finish...") self.worker.wait(5000) # Wait max 5 seconds if self.worker.isRunning(): logger.warning("Worker thread did not finish, terminating...") self.worker.terminate() self.worker.wait() event.accept()