Files
gti_radiostudio/history_widget.py
T

478 lines
15 KiB
Python
Raw Normal View History

2026-04-29 16:21:44 +10:00
# history_widget.py
import csv
import os
2026-04-29 16:21:44 +10:00
import threading
2026-04-29 16:21:44 +10:00
import musicbrainzngs
from mutagen import File as MutagenFile
from PySide6.QtWidgets import (
QFrame, QVBoxLayout, QHBoxLayout, QLabel,
QScrollArea, QWidget, QPushButton, QDialog,
QLineEdit, QFormLayout, QDialogButtonBox,
QFileDialog
2026-04-29 16:21:44 +10:00
)
from PySide6.QtCore import Qt, Signal, QSize
2026-04-29 16:21:44 +10:00
from PySide6.QtGui import QFont
from icons import icon_plus, icon_delete, icon_export
2026-04-29 16:21:44 +10:00
musicbrainzngs.set_useragent("RadioPanel", "1.0", "radio@panel.local")
def _read_file_tags(filepath: str) -> dict:
try:
tags = MutagenFile(filepath, easy=True)
if tags is None:
return {}
def get(key):
val = tags.get(key)
return val[0] if val else None
return {
'artist': get('artist'),
'title': get('title'),
'album': get('album'),
'year': get('date'),
}
except Exception:
return {}
def _lookup_musicbrainz(artist: str, title: str) -> dict:
try:
result = musicbrainzngs.search_recordings(
recording=title, artist=artist, limit=1
2026-04-29 16:21:44 +10:00
)
recordings = result.get('recording-list', [])
if not recordings:
return {}
rec = recordings[0]
2026-04-29 16:21:44 +10:00
releases = rec.get('release-list', [])
if not releases:
return {}
release = releases[0]
date = release.get('date', '')
return {
'album': release.get('title'),
'year': date[:4] if date else None,
}
2026-04-29 16:21:44 +10:00
except Exception:
return {}
class HistoryEntry(QFrame):
deleted = Signal(object)
def __init__(self, index: int, filepath: str, metadata: dict,
parent=None):
2026-04-29 16:21:44 +10:00
super().__init__(parent)
self._index = index
self._filepath = filepath
self._metadata = metadata
2026-04-29 16:21:44 +10:00
self.setFrameStyle(QFrame.Box)
self.setStyleSheet("""
QFrame {
background-color: #1a1a1a;
border: none;
border-bottom: 1px solid #2a2a2a;
}
""")
root = QHBoxLayout(self)
root.setContentsMargins(8, 6, 8, 6)
root.setSpacing(8)
text_col = QVBoxLayout()
text_col.setSpacing(2)
2026-04-29 16:21:44 +10:00
artist = metadata.get('artist') or 'Unknown Artist'
title = metadata.get('title') or self._filename(filepath)
top = QLabel(f"{index}. {artist}{title}")
top.setFont(QFont("Arial", 11, QFont.Bold))
top.setStyleSheet(
"color: #00ff41; background: transparent; border: none;"
)
top.setWordWrap(True)
text_col.addWidget(top)
2026-04-29 16:21:44 +10:00
album = metadata.get('album') or ''
year = metadata.get('year') or ''
parts = [p for p in [album, year[:4] if year and len(year) >= 4 else year] if p]
2026-04-29 16:21:44 +10:00
if parts:
sub = QLabel(" " + " · ".join(parts))
sub.setFont(QFont("Arial", 9))
sub.setStyleSheet(
2026-04-29 16:21:44 +10:00
"color: #888888; background: transparent; border: none;"
)
sub.setWordWrap(True)
text_col.addWidget(sub)
root.addLayout(text_col, stretch=1)
self._del_btn = QPushButton()
self._del_btn.setFixedSize(24, 24)
self._del_btn.setIconSize(QSize(14, 14))
self._del_btn.setIcon(icon_delete("#ff4444"))
self._del_btn.setStyleSheet("""
QPushButton {
background-color: #5a1a1a;
border: 1px solid #444;
border-radius: 12px;
}
QPushButton:hover { background-color: #7a2a2a; }
""")
self._del_btn.setToolTip("Remove from history")
self._del_btn.clicked.connect(lambda: self.deleted.emit(self))
root.addWidget(self._del_btn, alignment=Qt.AlignTop)
2026-04-29 16:21:44 +10:00
def _filename(self, filepath: str) -> str:
if not filepath:
return "Unknown"
2026-04-29 16:21:44 +10:00
name = os.path.basename(filepath)
return os.path.splitext(name)[0]
@property
def filepath(self) -> str:
return self._filepath
@property
def metadata(self) -> dict:
return self._metadata
class AddEntryDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Add to History")
self.setFixedWidth(380)
self.setStyleSheet("""
QDialog {
background-color: #1e1e1e;
color: #cccccc;
border: 2px solid #444;
border-radius: 6px;
}
QLabel {
color: #aaaaaa;
background: transparent;
border: none;
}
QLineEdit {
background-color: #111;
color: #00ff41;
border: 1px solid #333;
border-radius: 3px;
padding: 6px;
font-family: Courier New;
font-size: 12px;
}
QPushButton {
background-color: #2a2a2a;
color: #cccccc;
border: 1px solid #444;
border-radius: 3px;
padding: 6px 16px;
font-weight: bold;
}
QPushButton:hover { background-color: #444; }
""")
layout = QFormLayout(self)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(10)
self._artist = QLineEdit()
self._artist.setPlaceholderText("e.g. The Beatles")
layout.addRow("Artist:", self._artist)
self._title = QLineEdit()
self._title.setPlaceholderText("e.g. Hey Jude")
layout.addRow("Title:", self._title)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.button(QDialogButtonBox.Ok).setText("Add")
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addRow(buttons)
def values(self) -> dict:
return {
'artist': self._artist.text().strip(),
'title': self._title.text().strip(),
}
2026-04-29 16:21:44 +10:00
class HistoryWidget(QFrame):
_metadata_ready = Signal(str, dict)
def __init__(self, parent=None):
super().__init__(parent)
self._history = []
self._pending = {}
self._entry_index = 0
2026-04-29 16:21:44 +10:00
self.setFrameStyle(QFrame.Box | QFrame.Raised)
self.setStyleSheet("""
QFrame {
background-color: #111111;
border: 2px solid #336633;
border-radius: 6px;
}
""")
self._build_ui()
self._metadata_ready.connect(self._on_metadata_ready)
def _build_ui(self):
root = QVBoxLayout(self)
root.setContentsMargins(8, 8, 8, 8)
root.setSpacing(6)
# Header
2026-04-29 16:21:44 +10:00
header = QLabel("PLAY HISTORY")
header.setFont(QFont("Arial", 11, QFont.Bold))
header.setStyleSheet(
"color: #336633; background: transparent; border: none; padding: 2px;"
)
2026-04-29 16:21:44 +10:00
header.setAlignment(Qt.AlignCenter)
root.addWidget(header)
# Toolbar
bar = QHBoxLayout()
bar.setSpacing(4)
self._add_btn = QPushButton("ADD")
self._add_btn.setFixedHeight(28)
self._add_btn.setIconSize(QSize(16, 16))
self._add_btn.setIcon(icon_plus("#00ff41"))
self._add_btn.setFont(QFont("Arial", 9, QFont.Bold))
self._add_btn.setStyleSheet("""
QPushButton {
background-color: #2a4a2a;
color: #00ff41;
border: 1px solid #336633;
border-radius: 3px;
padding: 0 10px;
}
QPushButton:hover { background-color: #3a5a3a; }
""")
self._add_btn.clicked.connect(self._add_manual_entry)
bar.addWidget(self._add_btn)
bar.addStretch()
root.addLayout(bar)
# Scroll area
self._scroll = QScrollArea()
self._scroll.setWidgetResizable(True)
self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self._scroll.setStyleSheet("""
2026-04-29 16:21:44 +10:00
QScrollArea {
border: none;
background: transparent;
}
QScrollBar:vertical {
background: #1a1a1a;
width: 8px;
border-radius: 4px;
}
QScrollBar::handle:vertical {
background: #336633;
border-radius: 4px;
}
""")
self._list_widget = QWidget()
self._list_widget.setStyleSheet("background: transparent;")
self._list_layout = QVBoxLayout(self._list_widget)
self._list_layout.setContentsMargins(0, 0, 0, 0)
self._list_layout.setSpacing(0)
self._list_layout.addStretch()
self._scroll.setWidget(self._list_widget)
root.addWidget(self._scroll)
# Export bar
export_bar = QHBoxLayout()
export_bar.setSpacing(4)
self._export_txt_btn = QPushButton("EXPORT TXT")
self._export_txt_btn.setFixedHeight(28)
self._export_txt_btn.setIconSize(QSize(16, 16))
self._export_txt_btn.setIcon(icon_export("#aaaaaa"))
self._export_txt_btn.setFont(QFont("Arial", 9, QFont.Bold))
self._export_txt_btn.setStyleSheet("""
QPushButton {
background-color: #2a3a2a;
color: #aaaaaa;
border: 1px solid #444;
border-radius: 3px;
padding: 0 10px;
}
QPushButton:hover { background-color: #3a4a3a; }
""")
self._export_txt_btn.clicked.connect(self._export_txt)
self._export_csv_btn = QPushButton("EXPORT CSV")
self._export_csv_btn.setFixedHeight(28)
self._export_csv_btn.setIconSize(QSize(16, 16))
self._export_csv_btn.setIcon(icon_export("#aaaaaa"))
self._export_csv_btn.setFont(QFont("Arial", 9, QFont.Bold))
self._export_csv_btn.setStyleSheet("""
QPushButton {
background-color: #2a3a2a;
color: #aaaaaa;
border: 1px solid #444;
border-radius: 3px;
padding: 0 10px;
}
QPushButton:hover { background-color: #3a4a3a; }
""")
self._export_csv_btn.clicked.connect(self._export_csv)
2026-04-29 16:21:44 +10:00
export_bar.addWidget(self._export_txt_btn)
export_bar.addWidget(self._export_csv_btn)
export_bar.addStretch()
root.addLayout(export_bar)
2026-04-29 16:21:44 +10:00
2026-04-30 00:30:23 +10:00
def apply_theme(self, theme: dict):
self.setStyleSheet(f"""
QFrame {{
background-color: {theme['bg2']};
border: 2px solid {theme['border']};
border-radius: 6px;
}}
""")
2026-04-29 16:21:44 +10:00
def add_track(self, filepath: str):
idx = self._entry_index + 1
2026-04-29 16:21:44 +10:00
metadata = _read_file_tags(filepath)
self._history.append((filepath, metadata))
self._entry_index = idx
entry = self._insert_entry(idx, filepath, metadata)
2026-04-29 16:21:44 +10:00
if not metadata.get('artist') or not metadata.get('album'):
self._pending[filepath] = idx
2026-04-29 16:21:44 +10:00
threading.Thread(
target=self._enrich_metadata,
args=(filepath, metadata),
daemon=True
).start()
def _add_manual_entry(self):
dlg = AddEntryDialog(self)
if dlg.exec() != QDialog.Accepted:
return
vals = dlg.values()
if not vals['artist'] and not vals['title']:
return
idx = self._entry_index + 1
metadata = {
'artist': vals['artist'] or 'Unknown Artist',
'title': vals['title'] or 'Unknown Title',
'album': None,
'year': None,
}
self._history.append(("", metadata))
self._entry_index = idx
self._insert_entry(idx, "", metadata)
def _insert_entry(self, index: int, filepath: str,
metadata: dict) -> HistoryEntry:
2026-04-29 16:21:44 +10:00
entry = HistoryEntry(index, filepath, metadata)
entry.deleted.connect(self._delete_entry)
self._list_layout.insertWidget(0, entry)
self._scroll.verticalScrollBar().setValue(0)
return entry
def _delete_entry(self, entry: HistoryEntry):
idx = entry._index - 1
if 0 <= idx < len(self._history):
self._history.pop(idx)
entry.deleteLater()
self._rebuild_list()
2026-04-29 16:21:44 +10:00
def _enrich_metadata(self, filepath: str, existing: dict):
artist = existing.get('artist')
title = existing.get('title')
if not artist or not title:
return
mb_data = _lookup_musicbrainz(artist, title)
if not mb_data:
return
merged = dict(existing)
if not merged.get('album') and mb_data.get('album'):
merged['album'] = mb_data['album']
if not merged.get('year') and mb_data.get('year'):
merged['year'] = mb_data['year']
self._metadata_ready.emit(filepath, merged)
def _on_metadata_ready(self, filepath: str, metadata: dict):
for i, (fp, _) in enumerate(self._history):
if fp == filepath:
self._history[i] = (fp, metadata)
break
self._rebuild_list()
def _rebuild_list(self):
while self._list_layout.count() > 1:
item = self._list_layout.takeAt(0)
2026-04-29 16:21:44 +10:00
if item.widget():
item.widget().deleteLater()
for i, (filepath, metadata) in enumerate(reversed(self._history)):
index = len(self._history) - i
entry = HistoryEntry(index, filepath, metadata)
entry.deleted.connect(self._delete_entry)
self._list_layout.insertWidget(
self._list_layout.count() - 1, entry
)
def _iter_entries(self):
for i, (fp, md) in enumerate(reversed(self._history)):
idx = len(self._history) - i
artist = md.get('artist') or 'Unknown Artist'
title = md.get('title') or (os.path.splitext(os.path.basename(fp))[0] if fp else 'Unknown')
album = md.get('album') or ''
year = md.get('year') or ''
yield idx, artist, title, album, year
def _export_txt(self):
path, _ = QFileDialog.getSaveFileName(
self, "Export History (TXT)",
os.path.expanduser("~/play_history.txt"),
"Text Files (*.txt)"
)
if not path:
return
with open(path, 'w') as f:
for idx, artist, title, album, year in self._iter_entries():
f.write(f"{idx}. {artist}{title}")
if album or year:
parts = [p for p in [album,
year[:4] if year and len(year) >= 4 else year]
if p]
if parts:
f.write(f"\n {' · '.join(parts)}")
f.write("\n")
def _export_csv(self):
path, _ = QFileDialog.getSaveFileName(
self, "Export History (CSV)",
os.path.expanduser("~/play_history.csv"),
"CSV Files (*.csv)"
)
if not path:
return
with open(path, 'w', newline='') as f:
w = csv.writer(f)
w.writerow(["#", "Artist", "Title", "Album", "Year"])
for idx, artist, title, album, year in self._iter_entries():
w.writerow([idx, artist, title, album, year])