# history_widget.py import csv import os import threading import time from datetime import datetime LIVE_CSV_PATH = os.path.expanduser("~/.gti_radiostudio/live_songs.csv") 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, QTimer, Signal, QSize from PySide6.QtGui import QFont, QColor 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, entry_type: str, entry_data: tuple, parent=None): super().__init__(parent) self._index = index self._entry_type = entry_type self._entry_data = entry_data 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) if entry_type == 'song': filepath, metadata = entry_data artist = metadata.get('artist') or 'Unknown Artist' title = metadata.get('title') or self._filename(filepath) self._render_song(text_col, artist, title, metadata) elif entry_type == 'cart': track_name, = entry_data self._render_cart(text_col, track_name) elif entry_type == 'talk': duration, = entry_data self._render_talk(text_col, duration) 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] def _render_song(self, layout, artist: str, title: str, metadata: dict): top = QLabel(f"[SONG] {artist} — {title}") top.setFont(QFont("Arial", 11, QFont.Bold)) top.setStyleSheet("color: #00ff41; background: transparent; border: none;") top.setWordWrap(True) layout.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) layout.addWidget(sub) def _render_cart(self, layout, track_name: str): top = QLabel(f"[CART] {track_name}") top.setFont(QFont("Arial", 11, QFont.Bold)) top.setStyleSheet("color: #ffaa00; background: transparent; border: none;") top.setWordWrap(True) layout.addWidget(top) def _render_talk(self, layout, duration: float): dur_str = f"{int(duration//60):02d}:{int(duration%60):02d}" top = QLabel(f"[TALK] {dur_str}") top.setFont(QFont("Arial", 11, QFont.Bold)) top.setStyleSheet("color: #4488ff; background: transparent; border: none;") top.setWordWrap(True) layout.addWidget(top) @property def filepath(self) -> str: if self._entry_type == 'song': return self._entry_data[0] return "" @property def metadata(self) -> dict: if self._entry_type == 'song': return self._entry_data[1] return {} @property def entry_type(self) -> str: return self._entry_type @property def entry_data(self) -> tuple: return self._entry_data 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 ShowHistoryWidget(QFrame): def __init__(self, parent=None): super().__init__(parent) self._history = [] self._entry_index = 0 self._session_start = None self._mic_was_on = False 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) self._mic_timer = QTimer(self) self._mic_timer.setInterval(500) self._mic_timer.timeout.connect(self._poll_mic) self._mic_timer.start() _metadata_ready = Signal(str, dict) def _build_ui(self): root = QVBoxLayout(self) root.setContentsMargins(8, 8, 8, 8) root.setSpacing(6) header = QLabel("SHOW LOG") header.setFont(QFont("Arial", 11, QFont.Bold)) header.setStyleSheet( "color: #336633; background: transparent; border: none; padding: 2px;" ) header.setAlignment(Qt.AlignCenter) root.addWidget(header) 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) self._reset_btn = QPushButton("RESET LIVE") self._reset_btn.setFixedHeight(28) self._reset_btn.setFont(QFont("Arial", 9, QFont.Bold)) self._reset_btn.setStyleSheet(""" QPushButton { background-color: #3a1a1a; color: #ff6666; border: 1px solid #553333; border-radius: 3px; padding: 0 10px; } QPushButton:hover { background-color: #5a2a2a; } """) self._reset_btn.clicked.connect(self._reset_live_csv) bar.addWidget(self._reset_btn) bar.addStretch() root.addLayout(bar) 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 = 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 _elapsed_str(self, ts: datetime = None) -> str: now = datetime.now() if self._session_start is None: self._session_start = now delta = (ts or now) - self._session_start total_s = int(delta.total_seconds()) return f"{total_s//3600:02d}:{(total_s%3600)//60:02d}:{total_s%60:02d}" def add_song(self, filepath: str): idx = self._entry_index + 1 metadata = _read_file_tags(filepath) self._history.append(('song', (filepath, metadata))) self._entry_index = idx ts = self._elapsed_str() self._insert_entry(idx, 'song', (filepath, metadata), ts=ts) self._append_live_csv( metadata.get('artist') or 'Unknown Artist', metadata.get('title') or (os.path.splitext(os.path.basename(filepath))[0] if filepath else 'Unknown'), metadata.get('album') or '', metadata.get('year') or '', ) 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_cart(self, track_name: str): idx = self._entry_index + 1 self._history.append(('cart', (track_name,))) self._entry_index = idx ts = self._elapsed_str() self._insert_entry(idx, 'cart', (track_name,), ts) def add_talk(self, duration: float): if duration < 1: return idx = self._entry_index + 1 self._history.append(('talk', (duration,))) self._entry_index = idx ts = self._elapsed_str() self._insert_entry(idx, 'talk', (duration,), ts) def _poll_mic(self): if not hasattr(self, '_engine'): return channels = self._engine.mixer['channels'] any_on = any(ch.mic_on for ch in channels[:2]) if any_on and not self._mic_was_on: pass elif not any_on and self._mic_was_on: dur = self._engine.get_mic_duration() if dur > 1: self.add_talk(dur) self._mic_was_on = any_on def set_engine(self, engine): self._engine = engine 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(('song', ("", metadata))) self._entry_index = idx ts = self._elapsed_str() self._insert_entry(idx, 'song', ("", metadata), ts) _pending = {} def _insert_entry(self, index: int, entry_type: str, entry_data: tuple, ts: str = "") -> HistoryEntry: entry = HistoryEntry(index, entry_type, entry_data) 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() if entry.entry_type == 'song': self._rewrite_live_csv() 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, (entry_type, entry_data) in enumerate(self._history): if entry_type == 'song' and entry_data[0] == filepath: self._history[i] = ('song', (filepath, 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, (entry_type, entry_data) in enumerate(reversed(self._history)): index = len(self._history) - i entry = HistoryEntry(index, entry_type, entry_data) entry.deleted.connect(self._delete_entry) self._list_layout.insertWidget( self._list_layout.count() - 1, entry ) def _iter_entries(self): for entry_type, entry_data in reversed(self._history): if entry_type == 'song': fp, md = entry_data 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 ('SONG', f"{artist} — {title}", album, year) elif entry_type == 'cart': track_name, = entry_data yield ('CART', track_name, '', '') elif entry_type == 'talk': duration, = entry_data dur_str = f"{int(duration//60):02d}:{int(duration%60):02d}" yield ('TALK', dur_str, '', '') def _append_live_csv(self, artist: str, title: str, album: str, year: str): os.makedirs(os.path.dirname(LIVE_CSV_PATH), exist_ok=True) write_header = not os.path.exists(LIVE_CSV_PATH) with open(LIVE_CSV_PATH, 'a', newline='') as f: w = csv.writer(f) if write_header: w.writerow(["Artist", "Title", "Album", "Year"]) w.writerow([artist, title, album, year]) def _rewrite_live_csv(self): if not os.path.exists(LIVE_CSV_PATH): return with open(LIVE_CSV_PATH, 'w', newline='') as f: w = csv.writer(f) w.writerow(["Artist", "Title", "Album", "Year"]) for entry_type, entry_data in self._history: if entry_type == 'song': fp, md = entry_data 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 '' w.writerow([artist, title, album, year]) def _reset_live_csv(self): try: if os.path.exists(LIVE_CSV_PATH): os.remove(LIVE_CSV_PATH) except Exception: pass def _export_txt(self): path, _ = QFileDialog.getSaveFileName( self, "Export Show Log", os.path.expanduser("~/show_log.txt"), "Text Files (*.txt)" ) if not path: return now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") with open(path, 'w') as f: f.write(f"=== GTI Radio Studio — Show Log ===\n") f.write(f"Exported: {now}\n\n") for tag, text, album, year in self._iter_entries(): f.write(f"[{tag}] {text}") 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 Show Log (CSV)", os.path.expanduser("~/show_log.csv"), "CSV Files (*.csv)" ) if not path: return with open(path, 'w', newline='') as f: w = csv.writer(f) w.writerow(["#", "Type", "Text", "Album", "Year"]) for i, (tag, text, album, year) in enumerate(self._iter_entries()): w.writerow([i + 1, tag, text, album, year])