commit fbbb7cb8e884c2cd1cf3b9be9bdd86bc152d5fb6 Author: Bernd Date: Fri Jul 18 21:23:34 2025 +0500 initial commit 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 0000000..c433a17 Binary files /dev/null and b/__pycache__/backend.cpython-313.pyc differ diff --git a/backend.py b/backend.py new file mode 100644 index 0000000..2f032c1 --- /dev/null +++ b/backend.py @@ -0,0 +1,112 @@ +import sqlite3 +import csv + +class AnimeBackend: + def __init__(self): + self.db = sqlite3.connect('anime_backlog.db') + self.create_table() + + def create_table(self): + cursor = self.db.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS anime ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + year INTEGER NOT NULL, + season TEXT, + status TEXT NOT NULL DEFAULT 'unwatched', + type TEXT, + comment TEXT, + url TEXT + ) + """) + self.db.commit() + + def get_pre_2010_entries(self): + cursor = self.db.cursor() + cursor.execute("SELECT * FROM anime WHERE year < 2010 ORDER BY year DESC, name") + return cursor.fetchall() + + def get_years(self): + cursor = self.db.cursor() + cursor.execute("SELECT DISTINCT year FROM anime WHERE year >= 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_())