import sys import os import random import re import hashlib import html from datetime import datetime from PyQt5.QtWidgets import ( QMainWindow, QTabWidget, QScrollArea, QWidget, QVBoxLayout, QTableWidget, QTableWidgetItem, QLabel, QToolButton, QHBoxLayout, QDialog, QFormLayout, QLineEdit, QSpinBox, QComboBox, QTextEdit, QDialogButtonBox, QAction, QFileDialog, QMessageBox, QInputDialog, QApplication, QAbstractItemView, QSizePolicy, QHeaderView ) from PyQt5.QtCore import Qt, QUrl, QEvent from PyQt5.QtGui import QColor, QIcon from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply from backend import AnimeBackend class AnimeDialog(QDialog): def __init__(self, parent, entry=None, default_year=None, default_season=None): super().__init__(parent) self.setWindowTitle("Add Anime" if entry is None else "Edit Anime") layout = QFormLayout(self) self.name_edit = QLineEdit() layout.addRow("Name", self.name_edit) self.year_spin = QSpinBox() self.year_spin.setRange(1900, 2100) layout.addRow("Year", self.year_spin) self.season_combo = QComboBox() self.season_combo.addItems(['', 'winter', 'spring', 'summer', 'fall']) layout.addRow("Season", self.season_combo) self.type_combo = QComboBox() self.type_combo.addItems(['TV', 'Movie', 'OVA', 'Special', 'Short TV', 'Other']) layout.addRow("Type", self.type_combo) self.status_combo = QComboBox() self.status_combo.addItems(['unwatched', 'watching', 'completed']) layout.addRow("Status", self.status_combo) self.comment_edit = QTextEdit() layout.addRow("Comment", self.comment_edit) self.url_edit = QLineEdit() layout.addRow("MAL URL", self.url_edit) buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addRow(buttons) if entry: self.name_edit.setText(entry[1]) self.year_spin.setValue(entry[2]) self.season_combo.setCurrentText(entry[3]) self.status_combo.setCurrentText(entry[4]) self.type_combo.setCurrentText(entry[5] or '') self.comment_edit.setText(entry[6] or '') self.url_edit.setText(entry[7] or '') else: if default_year is not None: self.year_spin.setValue(default_year) if default_season is not None: self.season_combo.setCurrentText(default_season) self.year_spin.valueChanged.connect(self.update_season) self.update_season(self.year_spin.value()) def update_season(self, year): if year < 2010: self.season_combo.setEnabled(False) self.season_combo.setCurrentText('') else: self.season_combo.setEnabled(True) def get_data(self): return { 'name': self.name_edit.text(), 'year': self.year_spin.value(), 'season': self.season_combo.currentText(), 'status': self.status_combo.currentText(), 'type': self.type_combo.currentText(), 'comment': self.comment_edit.toPlainText(), 'url': self.url_edit.text() } class HoverLabel(QLabel): def __init__(self, main_window, name, url=None): super().__init__() self.main_window = main_window self.url = url self.fetched = False self.image_file = None if url: self.image_file = os.path.join(main_window.image_cache_dir, hashlib.md5(url.encode()).hexdigest() + '.jpg') name_escaped = html.escape(name) self.setText(f'{name_escaped}') self.setOpenExternalLinks(True) if os.path.exists(self.image_file): self.setToolTip(f'') self.fetched = True else: self.setText(html.escape(name)) def enterEvent(self, event): if self.url and not self.fetched: self.main_window.fetch_poster(self.url, self) self.fetched = True super().enterEvent(event) class AnimeTracker(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Anime Backlog Tracker") self.resize(800, 600) self.backend = AnimeBackend() self.image_cache_dir = 'images' os.makedirs(self.image_cache_dir, exist_ok=True) self.network_manager = QNetworkAccessManager(self) self.tab_widget = QTabWidget() self.setCentralWidget(self.tab_widget) self.create_menu() self.load_tabs() def create_menu(self): menubar = self.menuBar() file_menu = menubar.addMenu('File') import_act = QAction('Import CSV', self) import_act.triggered.connect(self.import_csv) file_menu.addAction(import_act) export_act = QAction('Export CSV', self) export_act.triggered.connect(self.export_csv) file_menu.addAction(export_act) edit_menu = menubar.addMenu('Edit') add_act = QAction('Add Anime', self) add_act.triggered.connect(self.add_anime) edit_menu.addAction(add_act) add_year_act = QAction('Add New Year', self) add_year_act.triggered.connect(self.add_new_year) edit_menu.addAction(add_year_act) del_year_act = QAction('Delete Year', self) del_year_act.triggered.connect(self.delete_year) edit_menu.addAction(del_year_act) tools_menu = menubar.addMenu('Tools') random_act = QAction('Random Pick', self) random_act.triggered.connect(self.random_pick) tools_menu.addAction(random_act) def load_tabs(self): self.tab_widget.clear() # Pre-2010 tab pre_entries = self.backend.get_pre_2010_entries() pre_tab = QScrollArea() pre_tab.setWidgetResizable(True) pre_content = QWidget() pre_layout = QVBoxLayout(pre_content) if pre_entries: table = self.create_table_widget(pre_entries, is_pre=True) pre_layout.addWidget(table) pre_tab.setWidget(pre_content) tab_text = "Pre-2010" completed = False total = len(pre_entries) if total > 0: comp = sum(1 for e in pre_entries if e[4] == 'completed') perc = (comp / total * 100) tab_text += f" ({perc:.0f}%)" if comp == total: completed = True index = self.tab_widget.addTab(pre_tab, tab_text) if completed: self.tab_widget.tabBar().setTabTextColor(index, QColor('gray')) # Years >= 2010 years = self.backend.get_years() for year in years: year_tab = QScrollArea() year_tab.setWidgetResizable(True) content = QWidget() layout = QVBoxLayout(content) total_entries = 0 comp_entries = 0 for season in ['winter', 'spring', 'summer', 'fall', '']: entries = self.backend.get_entries_for_season(year, season) if entries: s_name = season.capitalize() if season else 'Other' label = QLabel(s_name) layout.addWidget(label) table = self.create_table_widget(entries, is_pre=False) layout.addWidget(table) total_entries += len(entries) comp_entries += sum(1 for e in entries if e[4] == 'completed') year_tab.setWidget(content) if total_entries > 0: perc = (comp_entries / total_entries * 100) if total_entries > 0 else 0 tab_text = f"{year} ({perc:.0f}%)" completed = (comp_entries == total_entries) index = self.tab_widget.addTab(year_tab, tab_text) if completed: self.tab_widget.tabBar().setTabTextColor(index, QColor('gray')) def create_table_widget(self, entries, is_pre): col_count = 6 if is_pre else 5 table = QTableWidget(len(entries), col_count) headers = ['Year', 'Name', 'Type', 'Status', 'Comment', 'Actions'] if is_pre else ['Name', 'Type', 'Status', 'Comment', 'Actions'] table.setHorizontalHeaderLabels(headers) table.setAlternatingRowColors(True) table.setShowGrid(True) header = table.horizontalHeader() header.setStretchLastSection(False) table.setSelectionBehavior(QAbstractItemView.SelectRows) table.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) if is_pre: header.setSectionResizeMode(0, QHeaderView.ResizeToContents) # Year header.setSectionResizeMode(1, QHeaderView.Stretch) # Name header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Type header.setSectionResizeMode(3, QHeaderView.ResizeToContents) # Status header.setSectionResizeMode(4, QHeaderView.Stretch) # Comment header.setSectionResizeMode(5, QHeaderView.ResizeToContents) # Actions else: header.setSectionResizeMode(0, QHeaderView.Stretch) # Name header.setSectionResizeMode(1, QHeaderView.ResizeToContents) # Type header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Status header.setSectionResizeMode(3, QHeaderView.Stretch) # Comment header.setSectionResizeMode(4, QHeaderView.ResizeToContents) # Actions for row, entry in enumerate(entries): col = 0 if is_pre: year_item = QTableWidgetItem(str(entry[2])) table.setItem(row, col, year_item) col += 1 # Name name = entry[1] url = entry[7] name_label = HoverLabel(self, name, url) table.setCellWidget(row, col, name_label) col += 1 # Type type_item = QTableWidgetItem(entry[5] or '') table.setItem(row, col, type_item) col += 1 # Status status_item = QTableWidgetItem(entry[4]) table.setItem(row, col, status_item) col += 1 # Comment comment_item = QTableWidgetItem(entry[6] or '') table.setItem(row, col, comment_item) col += 1 # Actions actions_widget = self.create_actions_widget(entry[0], entry[4]) table.setCellWidget(row, col, actions_widget) table.resizeColumnsToContents() table.resizeRowsToContents() # Calculate fixed height height = table.horizontalHeader().height() + 2 # small margin for i in range(table.rowCount()): height += table.rowHeight(i) table.setFixedHeight(height) # Apply status colors status_col = 3 if is_pre else 2 for r in range(table.rowCount()): status = table.item(r, status_col).text() if status == 'unwatched': color = QColor(255, 255, 255) elif status == 'watching': color = QColor(255, 255, 0) elif status == 'completed': color = QColor(0, 255, 0) else: color = QColor(255, 255, 255) for c in range(table.columnCount()): item = table.item(r, c) if item: item.setBackground(color) widget = table.cellWidget(r, c) if widget: widget.setStyleSheet(f"background-color: {color.name()};") return table def create_actions_widget(self, anime_id, status): widget = QWidget() layout = QHBoxLayout(widget) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(2) # Edit edit_btn = QToolButton() edit_btn.setText("E") edit_btn.setToolTip("Edit") edit_btn.clicked.connect(lambda: self.edit_anime(anime_id)) layout.addWidget(edit_btn) # Delete del_btn = QToolButton() del_btn.setText("D") del_btn.setToolTip("Delete") del_btn.clicked.connect(lambda: self.delete_anime(anime_id)) layout.addWidget(del_btn) # Status buttons for st in ['unwatched', 'watching', 'completed']: btn = QToolButton() btn.setText(st[0].upper()) btn.setToolTip(f"Set to {st}") if st == status: btn.setEnabled(False) btn.clicked.connect(lambda _, s=st: self.change_status(anime_id, s)) layout.addWidget(btn) return widget def fetch_poster(self, url, label): image_file = label.image_file if os.path.exists(image_file): label.setToolTip(f'') return request = QNetworkRequest(QUrl(url)) reply = self.network_manager.get(request) reply.finished.connect(lambda: self.handle_html_reply(reply, image_file, label)) def handle_html_reply(self, reply, image_file, label): if reply.error() != QNetworkReply.NoError: label.setToolTip('Failed to load poster') return html = reply.readAll().data().decode('utf-8', errors='ignore') match = re.search(r'') else: label.setToolTip('Failed to load poster') def add_anime(self): tab_index = self.tab_widget.currentIndex() if tab_index == -1: default_year = 2010 default_season = '' else: tab_text = self.tab_widget.tabText(tab_index) if 'Pre-2010' in tab_text: default_year = 2009 default_season = '' else: parts = tab_text.split(' (') default_year = int(parts[0]) default_season = '' dialog = AnimeDialog(self, None, default_year, default_season) if dialog.exec_() == QDialog.Accepted: data = dialog.get_data() self.backend.add_anime(data) self.load_tabs() def edit_anime(self, anime_id): entry = self.backend.get_anime_by_id(anime_id) if entry: dialog = AnimeDialog(self, entry) if dialog.exec_() == QDialog.Accepted: data = dialog.get_data() self.backend.edit_anime(anime_id, data) self.load_tabs() def delete_anime(self, anime_id): if QMessageBox.question(self, "Confirm Delete", "Are you sure you want to delete this entry?") == QMessageBox.Yes: self.backend.delete_anime(anime_id) self.load_tabs() def change_status(self, anime_id, new_status): self.backend.change_status(anime_id, new_status) self.load_tabs() def add_new_year(self): current_year = datetime.now().year year, ok = QInputDialog.getInt(self, "Add New Year", "Enter the year to add:", current_year + 1, 2010, 2100) if ok: self.backend.add_placeholders_for_year(year) self.load_tabs() def delete_year(self): current_year = datetime.now().year year, ok = QInputDialog.getInt(self, "Delete Year", "Enter the year to delete:", 2010, 2010, current_year + 10) if ok: years = self.backend.get_years() if year not in years: QMessageBox.warning(self, "Error", f"Year {year} does not exist.") return if QMessageBox.question(self, "Confirm Delete", f"Are you sure you want to delete all entries for {year}?") == QMessageBox.Yes: self.backend.delete_year(year) self.load_tabs() def random_pick(self): if self.tab_widget.currentIndex() == -1: QMessageBox.information(self, "Random Pick", "No tab selected.") return tab_text = self.tab_widget.tabText(self.tab_widget.currentIndex()) is_pre = 'Pre-2010' in tab_text current_widget = self.tab_widget.currentWidget().widget() if isinstance(self.tab_widget.currentWidget(), QScrollArea) else self.tab_widget.currentWidget() tables = current_widget.findChildren(QTableWidget) name_col = 1 if is_pre else 0 status_col = 3 if is_pre else 2 unwatched = [] for table in tables: for row in range(table.rowCount()): status = table.item(row, status_col).text() if status == 'unwatched': name_text = table.cellWidget(row, name_col).text() clean_name = re.sub(r'<[^>]+>', '', name_text) unwatched.append(clean_name) if unwatched: random_name = random.choice(unwatched) QMessageBox.information(self, "Random Pick", f"Watch: {random_name}") else: QMessageBox.information(self, "Random Pick", "No unwatched anime in this tab.") def import_csv(self): file_name, _ = QFileDialog.getOpenFileName(self, "Import CSV", "", "CSV Files (*.csv)") if file_name: self.backend.import_from_csv(file_name) self.load_tabs() def export_csv(self): file_name, _ = QFileDialog.getSaveFileName(self, "Export CSV", "anime_backlog.csv", "CSV Files (*.csv)") if file_name: self.backend.export_to_csv(file_name) if __name__ == '__main__': app = QApplication(sys.argv) window = AnimeTracker() window.show() sys.exit(app.exec_())