# history_widget.py import csv import os import threading import musicbrainzngs from mutagen import File as MutagenFile from PySide6.QtWidgets import ( QFrame, QVBoxLayout, QHBoxLayout, QLabel, QScrollArea, QWidget, QPushButton, QDialog, QLineEdit, QFormLayout, QDialogButtonBox, QFileDialog ) from PySide6.QtCore import Qt, Signal, QSize from PySide6.QtGui import QFont from icons import icon_plus, icon_delete, icon_export 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 ) recordings = result.get('recording-list', []) if not recordings: return {} rec = recordings[0] 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, } except Exception: return {} class HistoryEntry(QFrame): deleted = Signal(object) def __init__(self, index: int, filepath: str, metadata: dict, parent=None): super().__init__(parent) self._index = index self._filepath = filepath self._metadata = metadata 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) 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) 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] if parts: sub = QLabel(" " + " · ".join(parts)) sub.setFont(QFont("Arial", 9)) sub.setStyleSheet( "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) def _filename(self, filepath: str) -> str: if not filepath: return "Unknown" 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(), } class HistoryWidget(QFrame): _metadata_ready = Signal(str, dict) def __init__(self, parent=None): super().__init__(parent) self._history = [] self._pending = {} self._entry_index = 0 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 header = QLabel("PLAY HISTORY") header.setFont(QFont("Arial", 11, QFont.Bold)) header.setStyleSheet( "color: #336633; background: transparent; border: none; padding: 2px;" ) 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(""" 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) export_bar.addWidget(self._export_txt_btn) export_bar.addWidget(self._export_csv_btn) export_bar.addStretch() root.addLayout(export_bar) def apply_theme(self, theme: dict): self.setStyleSheet(f""" QFrame {{ background-color: {theme['bg2']}; border: 2px solid {theme['border']}; border-radius: 6px; }} """) def add_track(self, filepath: str): idx = self._entry_index + 1 metadata = _read_file_tags(filepath) self._history.append((filepath, metadata)) self._entry_index = idx entry = self._insert_entry(idx, filepath, metadata) if not metadata.get('artist') or not metadata.get('album'): self._pending[filepath] = idx 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: 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() 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) 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])