initial commit
This commit is contained in:
commit
fbbb7cb8e8
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
anime_backlog.db
|
||||
./images
|
||||
./__pycache__
|
BIN
__pycache__/backend.cpython-313.pyc
Normal file
BIN
__pycache__/backend.cpython-313.pyc
Normal file
Binary file not shown.
112
backend.py
Normal file
112
backend.py
Normal file
@ -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)
|
357
frontend.py
Normal file
357
frontend.py
Normal file
@ -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'<a href="{url}">{name}</a>')
|
||||
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'<img src="{image_file}" width="200">')
|
||||
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'<meta property="og:image" content="([^"]+)"', html)
|
||||
if match:
|
||||
image_url = match.group(1)
|
||||
img_request = QNetworkRequest(QUrl(image_url))
|
||||
img_reply = self.network_manager.get(img_request)
|
||||
img_reply.finished.connect(lambda: self.handle_image_reply(img_reply, image_file, label))
|
||||
else:
|
||||
label.setToolTip('No poster found')
|
||||
|
||||
def handle_image_reply(self, reply, image_file, label):
|
||||
if reply.error() == QNetworkReply.NoError:
|
||||
with open(image_file, 'wb') as f:
|
||||
f.write(reply.readAll().data())
|
||||
label.setToolTip(f'<img src="{image_file}" width="200">')
|
||||
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_())
|
Loading…
Reference in New Issue
Block a user