2026-04-29 16:21:44 +10:00
|
|
|
# history_widget.py
|
|
|
|
|
|
2026-05-08 17:19:11 +10:00
|
|
|
import csv
|
|
|
|
|
import os
|
2026-04-29 16:21:44 +10:00
|
|
|
import threading
|
2026-07-17 20:44:15 +10:00
|
|
|
import time
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
|
|
|
|
LIVE_CSV_PATH = os.path.expanduser("~/.gti_radiostudio/live_songs.csv")
|
2026-05-08 17:19:11 +10:00
|
|
|
|
2026-04-29 16:21:44 +10:00
|
|
|
import musicbrainzngs
|
|
|
|
|
from mutagen import File as MutagenFile
|
|
|
|
|
from PySide6.QtWidgets import (
|
2026-05-08 17:19:11 +10:00
|
|
|
QFrame, QVBoxLayout, QHBoxLayout, QLabel,
|
|
|
|
|
QScrollArea, QWidget, QPushButton, QDialog,
|
|
|
|
|
QLineEdit, QFormLayout, QDialogButtonBox,
|
|
|
|
|
QFileDialog
|
2026-04-29 16:21:44 +10:00
|
|
|
)
|
2026-07-17 20:44:15 +10:00
|
|
|
from PySide6.QtCore import Qt, QTimer, Signal, QSize
|
|
|
|
|
from PySide6.QtGui import QFont, QColor
|
2026-04-29 16:21:44 +10:00
|
|
|
|
2026-05-08 18:33:01 +10:00
|
|
|
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(
|
2026-05-08 17:19:11 +10:00
|
|
|
recording=title, artist=artist, limit=1
|
2026-04-29 16:21:44 +10:00
|
|
|
)
|
|
|
|
|
recordings = result.get('recording-list', [])
|
|
|
|
|
if not recordings:
|
|
|
|
|
return {}
|
2026-05-08 17:19:11 +10:00
|
|
|
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', '')
|
2026-05-08 17:19:11 +10:00
|
|
|
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):
|
2026-05-08 17:19:11 +10:00
|
|
|
deleted = Signal(object)
|
|
|
|
|
|
2026-07-17 20:44:15 +10:00
|
|
|
def __init__(self, index: int, entry_type: str, entry_data: tuple,
|
2026-05-08 17:19:11 +10:00
|
|
|
parent=None):
|
2026-04-29 16:21:44 +10:00
|
|
|
super().__init__(parent)
|
2026-07-17 20:44:15 +10:00
|
|
|
self._index = index
|
|
|
|
|
self._entry_type = entry_type
|
|
|
|
|
self._entry_data = entry_data
|
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;
|
|
|
|
|
}
|
|
|
|
|
""")
|
|
|
|
|
|
2026-05-08 17:19:11 +10:00
|
|
|
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
|
|
|
|
2026-07-17 20:44:15 +10:00
|
|
|
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)
|
2026-05-08 17:19:11 +10:00
|
|
|
|
|
|
|
|
root.addLayout(text_col, stretch=1)
|
|
|
|
|
|
2026-05-08 18:33:01 +10:00
|
|
|
self._del_btn = QPushButton()
|
2026-05-08 17:19:11 +10:00
|
|
|
self._del_btn.setFixedSize(24, 24)
|
2026-05-08 18:33:01 +10:00
|
|
|
self._del_btn.setIconSize(QSize(14, 14))
|
|
|
|
|
self._del_btn.setIcon(icon_delete("#ff4444"))
|
2026-05-08 17:19:11 +10:00
|
|
|
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:
|
2026-05-08 17:19:11 +10:00
|
|
|
if not filepath:
|
|
|
|
|
return "Unknown"
|
2026-04-29 16:21:44 +10:00
|
|
|
name = os.path.basename(filepath)
|
|
|
|
|
return os.path.splitext(name)[0]
|
|
|
|
|
|
2026-07-17 20:44:15 +10:00
|
|
|
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)
|
|
|
|
|
|
2026-05-08 17:19:11 +10:00
|
|
|
@property
|
|
|
|
|
def filepath(self) -> str:
|
2026-07-17 20:44:15 +10:00
|
|
|
if self._entry_type == 'song':
|
|
|
|
|
return self._entry_data[0]
|
|
|
|
|
return ""
|
2026-05-08 17:19:11 +10:00
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def metadata(self) -> dict:
|
2026-07-17 20:44:15 +10:00
|
|
|
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
|
2026-05-08 17:19:11 +10:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
2026-07-17 20:44:15 +10:00
|
|
|
class ShowHistoryWidget(QFrame):
|
2026-04-29 16:21:44 +10:00
|
|
|
def __init__(self, parent=None):
|
|
|
|
|
super().__init__(parent)
|
2026-05-08 17:19:11 +10:00
|
|
|
self._history = []
|
|
|
|
|
self._entry_index = 0
|
2026-07-17 20:44:15 +10:00
|
|
|
self._session_start = None
|
|
|
|
|
self._mic_was_on = False
|
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)
|
|
|
|
|
|
2026-07-17 20:44:15 +10:00
|
|
|
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)
|
|
|
|
|
|
2026-04-29 16:21:44 +10:00
|
|
|
def _build_ui(self):
|
|
|
|
|
root = QVBoxLayout(self)
|
|
|
|
|
root.setContentsMargins(8, 8, 8, 8)
|
|
|
|
|
root.setSpacing(6)
|
|
|
|
|
|
2026-07-17 20:44:15 +10:00
|
|
|
header = QLabel("SHOW LOG")
|
2026-04-29 16:21:44 +10:00
|
|
|
header.setFont(QFont("Arial", 11, QFont.Bold))
|
2026-05-08 17:19:11 +10:00
|
|
|
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)
|
|
|
|
|
|
2026-05-08 17:19:11 +10:00
|
|
|
bar = QHBoxLayout()
|
|
|
|
|
bar.setSpacing(4)
|
|
|
|
|
|
2026-05-08 18:33:01 +10:00
|
|
|
self._add_btn = QPushButton("ADD")
|
2026-05-08 17:19:11 +10:00
|
|
|
self._add_btn.setFixedHeight(28)
|
2026-05-08 18:33:01 +10:00
|
|
|
self._add_btn.setIconSize(QSize(16, 16))
|
|
|
|
|
self._add_btn.setIcon(icon_plus("#00ff41"))
|
2026-05-08 17:19:11 +10:00
|
|
|
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)
|
|
|
|
|
|
2026-07-17 20:44:15 +10:00
|
|
|
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)
|
|
|
|
|
|
2026-05-08 17:19:11 +10:00
|
|
|
bar.addStretch()
|
|
|
|
|
root.addLayout(bar)
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
""")
|
|
|
|
|
|
2026-05-08 17:19:11 +10:00
|
|
|
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)
|
2026-05-08 18:33:01 +10:00
|
|
|
self._export_txt_btn.setIconSize(QSize(16, 16))
|
|
|
|
|
self._export_txt_btn.setIcon(icon_export("#aaaaaa"))
|
2026-05-08 17:19:11 +10:00
|
|
|
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)
|
2026-05-08 18:33:01 +10:00
|
|
|
self._export_csv_btn.setIconSize(QSize(16, 16))
|
|
|
|
|
self._export_csv_btn.setIcon(icon_export("#aaaaaa"))
|
2026-05-08 17:19:11 +10:00
|
|
|
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
|
|
|
|
2026-05-08 17:19:11 +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-07-17 20:44:15 +10:00
|
|
|
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):
|
2026-05-08 17:19:11 +10:00
|
|
|
idx = self._entry_index + 1
|
2026-04-29 16:21:44 +10:00
|
|
|
metadata = _read_file_tags(filepath)
|
2026-07-17 20:44:15 +10:00
|
|
|
self._history.append(('song', (filepath, metadata)))
|
2026-05-08 17:19:11 +10:00
|
|
|
self._entry_index = idx
|
|
|
|
|
|
2026-07-17 20:44:15 +10:00
|
|
|
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 '',
|
|
|
|
|
)
|
2026-04-29 16:21:44 +10:00
|
|
|
|
|
|
|
|
if not metadata.get('artist') or not metadata.get('album'):
|
2026-05-08 17:19:11 +10:00
|
|
|
self._pending[filepath] = idx
|
2026-04-29 16:21:44 +10:00
|
|
|
threading.Thread(
|
|
|
|
|
target=self._enrich_metadata,
|
|
|
|
|
args=(filepath, metadata),
|
|
|
|
|
daemon=True
|
|
|
|
|
).start()
|
|
|
|
|
|
2026-07-17 20:44:15 +10:00
|
|
|
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
|
|
|
|
|
|
2026-05-08 17:19:11 +10:00
|
|
|
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,
|
|
|
|
|
}
|
2026-07-17 20:44:15 +10:00
|
|
|
self._history.append(('song', ("", metadata)))
|
2026-05-08 17:19:11 +10:00
|
|
|
self._entry_index = idx
|
2026-07-17 20:44:15 +10:00
|
|
|
ts = self._elapsed_str()
|
|
|
|
|
self._insert_entry(idx, 'song', ("", metadata), ts)
|
|
|
|
|
|
|
|
|
|
_pending = {}
|
2026-05-08 17:19:11 +10:00
|
|
|
|
2026-07-17 20:44:15 +10:00
|
|
|
def _insert_entry(self, index: int, entry_type: str,
|
|
|
|
|
entry_data: tuple, ts: str = "") -> HistoryEntry:
|
|
|
|
|
entry = HistoryEntry(index, entry_type, entry_data)
|
2026-05-08 17:19:11 +10:00
|
|
|
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-07-17 20:44:15 +10:00
|
|
|
if entry.entry_type == 'song':
|
|
|
|
|
self._rewrite_live_csv()
|
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):
|
2026-07-17 20:44:15 +10:00
|
|
|
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))
|
2026-04-29 16:21:44 +10:00
|
|
|
break
|
|
|
|
|
self._rebuild_list()
|
|
|
|
|
|
|
|
|
|
def _rebuild_list(self):
|
2026-05-08 17:19:11 +10:00
|
|
|
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()
|
|
|
|
|
|
2026-07-17 20:44:15 +10:00
|
|
|
for i, (entry_type, entry_data) in enumerate(reversed(self._history)):
|
2026-04-29 16:21:44 +10:00
|
|
|
index = len(self._history) - i
|
2026-07-17 20:44:15 +10:00
|
|
|
entry = HistoryEntry(index, entry_type, entry_data)
|
2026-05-08 17:19:11 +10:00
|
|
|
entry.deleted.connect(self._delete_entry)
|
|
|
|
|
self._list_layout.insertWidget(
|
|
|
|
|
self._list_layout.count() - 1, entry
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _iter_entries(self):
|
2026-07-17 20:44:15 +10:00
|
|
|
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
|
2026-05-08 17:19:11 +10:00
|
|
|
|
|
|
|
|
def _export_txt(self):
|
|
|
|
|
path, _ = QFileDialog.getSaveFileName(
|
2026-07-17 20:44:15 +10:00
|
|
|
self, "Export Show Log",
|
|
|
|
|
os.path.expanduser("~/show_log.txt"),
|
2026-05-08 17:19:11 +10:00
|
|
|
"Text Files (*.txt)"
|
|
|
|
|
)
|
|
|
|
|
if not path:
|
|
|
|
|
return
|
2026-07-17 20:44:15 +10:00
|
|
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
2026-05-08 17:19:11 +10:00
|
|
|
with open(path, 'w') as f:
|
2026-07-17 20:44:15 +10:00
|
|
|
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}")
|
2026-05-08 17:19:11 +10:00
|
|
|
if album or year:
|
2026-07-17 20:44:15 +10:00
|
|
|
parts = [p for p in [album, year[:4] if year and len(year) >= 4 else year] if p]
|
2026-05-08 17:19:11 +10:00
|
|
|
if parts:
|
|
|
|
|
f.write(f"\n {' · '.join(parts)}")
|
|
|
|
|
f.write("\n")
|
|
|
|
|
|
|
|
|
|
def _export_csv(self):
|
|
|
|
|
path, _ = QFileDialog.getSaveFileName(
|
2026-07-17 20:44:15 +10:00
|
|
|
self, "Export Show Log (CSV)",
|
|
|
|
|
os.path.expanduser("~/show_log.csv"),
|
2026-05-08 17:19:11 +10:00
|
|
|
"CSV Files (*.csv)"
|
|
|
|
|
)
|
|
|
|
|
if not path:
|
|
|
|
|
return
|
|
|
|
|
with open(path, 'w', newline='') as f:
|
|
|
|
|
w = csv.writer(f)
|
2026-07-17 20:44:15 +10:00
|
|
|
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])
|