From fbbb7cb8e884c2cd1cf3b9be9bdd86bc152d5fb6 Mon Sep 17 00:00:00 2001 From: Bernd Date: Fri, 18 Jul 2025 21:23:34 +0500 Subject: [PATCH] initial commit --- .gitignore | 3 + __pycache__/backend.cpython-313.pyc | Bin 0 -> 7331 bytes backend.py | 112 +++++++++ frontend.py | 357 ++++++++++++++++++++++++++++ 4 files changed, 472 insertions(+) create mode 100644 .gitignore create mode 100644 __pycache__/backend.cpython-313.pyc create mode 100644 backend.py create mode 100644 frontend.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e1d80f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +anime_backlog.db +./images +./__pycache__ diff --git a/__pycache__/backend.cpython-313.pyc b/__pycache__/backend.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c433a17613446026bec6f4a0d45a351a33197ea5 GIT binary patch literal 7331 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 2010 ORDER BY year DESC") + return [row[0] for row in cursor.fetchall()] + + def get_entries_for_season(self, year, season): + cursor = self.db.cursor() + cursor.execute("SELECT * FROM anime WHERE year = ? AND season = ? ORDER BY name", (year, season)) + return cursor.fetchall() + + def get_anime_by_id(self, anime_id): + cursor = self.db.cursor() + cursor.execute("SELECT * FROM anime WHERE id = ?", (anime_id,)) + return cursor.fetchone() + + def add_anime(self, data): + cursor = self.db.cursor() + cursor.execute( + "INSERT INTO anime (name, year, season, status, type, comment, url) VALUES (?, ?, ?, ?, ?, ?, ?)", + (data['name'], data['year'], data['season'], data['status'], data['type'], data['comment'], data['url']) + ) + self.db.commit() + + def edit_anime(self, anime_id, data): + cursor = self.db.cursor() + cursor.execute( + "UPDATE anime SET name=?, year=?, season=?, status=?, type=?, comment=?, url=? WHERE id=?", + (data['name'], data['year'], data['season'], data['status'], data['type'], data['comment'], data['url'], anime_id) + ) + self.db.commit() + + def delete_anime(self, anime_id): + cursor = self.db.cursor() + cursor.execute("DELETE FROM anime WHERE id = ?", (anime_id,)) + self.db.commit() + + def change_status(self, anime_id, new_status): + cursor = self.db.cursor() + cursor.execute("UPDATE anime SET status = ? WHERE id = ?", (new_status, anime_id)) + self.db.commit() + + def add_placeholders_for_year(self, year): + cursor = self.db.cursor() + for season in ['winter', 'spring', 'summer', 'fall', '']: + cursor.execute( + "INSERT INTO anime (name, year, season, status, type, comment, url) VALUES (?, ?, ?, ?, ?, ?, ?)", + ('Placeholder', year, season, 'unwatched', '', 'Delete or edit me', '') + ) + self.db.commit() + + def import_from_csv(self, file_name): + with open(file_name, 'r', newline='') as f: + reader = csv.reader(f) + header = next(reader, None) + if header: + cursor = self.db.cursor() + for row in reader: + if len(row) < 8: + continue + _, name, year, season, status, type_, comment, url = row + try: + year = int(year) + except ValueError: + continue + cursor.execute( + "SELECT id FROM anime WHERE name = ? AND year = ? AND season = ?", + (name, year, season) + ) + if not cursor.fetchone(): + cursor.execute( + "INSERT INTO anime (name, year, season, status, type, comment, url) VALUES (?, ?, ?, ?, ?, ?, ?)", + (name, year, season, status, type_, comment, url) + ) + self.db.commit() + + def export_to_csv(self, file_name): + cursor = self.db.cursor() + cursor.execute("SELECT * FROM anime") + rows = cursor.fetchall() + with open(file_name, 'w', newline='') as f: + writer = csv.writer(f) + writer.writerow(['id', 'name', 'year', 'season', 'status', 'type', 'comment', 'url']) + writer.writerows(rows) diff --git a/frontend.py b/frontend.py new file mode 100644 index 0000000..b3d5370 --- /dev/null +++ b/frontend.py @@ -0,0 +1,357 @@ +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_())