import sys import os import random import re import hashlib from datetime import datetime from PyQt5.QtWidgets import ( QMainWindow, QTabWidget, QWidget, QVBoxLayout, QTableWidget, QTableWidgetItem, QLabel, QToolButton, QHBoxLayout, QDialog, QFormLayout, QLineEdit, QSpinBox, QComboBox, QTextEdit, QDialogButtonBox, QAction, QFileDialog, QMessageBox, QInputDialog, QApplication, QAbstractItemView ) from PyQt5.QtCore import Qt, QUrl 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 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) 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_entries = self.backend.get_pre_2010_entries() pre_tab = QWidget() self.setup_table(pre_tab, pre_entries, is_pre=True) 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 = self.backend.get_years() for year in years: for season in ['winter', 'spring', 'summer', 'fall', '']: entries = self.backend.get_entries_for_season(year, season) if entries: tab = QWidget() self.setup_table(tab, entries, is_pre=False, year=year, season=season) s_name = season.capitalize() if season else 'Other' total = len(entries) comp = sum(1 for e in entries if e[4] == 'completed') perc = (comp / total * 100) tab_text = f"{year} - {s_name} ({perc:.0f}%)" completed = (comp == total) index = self.tab_widget.addTab(tab, tab_text) if completed: self.tab_widget.tabBar().setTabTextColor(index, QColor('gray')) def setup_table(self, tab, entries, is_pre, year=None, season=None): layout = QVBoxLayout(tab) 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) table.horizontalHeader().setStretchLastSection(True) table.setSelectionBehavior(QAbstractItemView.SelectRows) 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 = QLabel() if url: name_label.setText(f'{name}') name_label.setOpenExternalLinks(True) self.fetch_poster(url, name_label) else: name_label.setText(name) 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() # 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()};") layout.addWidget(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 = os.path.join(self.image_cache_dir, hashlib.md5(url.encode()).hexdigest() + '.jpg') 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]) s_name = parts[1].split(' (')[0].lower() default_season = '' if s_name == 'other' else s_name 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 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 table = self.tab_widget.currentWidget().findChild(QTableWidget) if not table: return name_col = 1 if is_pre else 0 status_col = 3 if is_pre else 2 unwatched = [] 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_())