v0.2 - drop fingerprinting, add LUFS cart normalisation, playlist save/load, editable history with TXT+CSV export
This commit is contained in:
+299
-77
@@ -1,11 +1,17 @@
|
||||
# history_widget.py
|
||||
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import threading
|
||||
|
||||
import musicbrainzngs
|
||||
from mutagen import File as MutagenFile
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame, QVBoxLayout, QLabel,
|
||||
QScrollArea, QWidget
|
||||
QFrame, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QScrollArea, QWidget, QPushButton, QDialog,
|
||||
QLineEdit, QFormLayout, QDialogButtonBox,
|
||||
QFileDialog
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QFont
|
||||
@@ -18,11 +24,9 @@ def _read_file_tags(filepath: str) -> dict:
|
||||
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'),
|
||||
@@ -36,35 +40,34 @@ def _read_file_tags(filepath: str) -> dict:
|
||||
def _lookup_musicbrainz(artist: str, title: str) -> dict:
|
||||
try:
|
||||
result = musicbrainzngs.search_recordings(
|
||||
recording=title,
|
||||
artist=artist,
|
||||
limit=1
|
||||
recording=title, artist=artist, limit=1
|
||||
)
|
||||
recordings = result.get('recording-list', [])
|
||||
if not recordings:
|
||||
return {}
|
||||
|
||||
rec = recordings[0]
|
||||
rec = recordings[0]
|
||||
releases = rec.get('release-list', [])
|
||||
if not releases:
|
||||
return {}
|
||||
|
||||
release = releases[0]
|
||||
album = release.get('title', None)
|
||||
year = None
|
||||
|
||||
date = release.get('date', '')
|
||||
if date:
|
||||
year = date[:4]
|
||||
|
||||
return {'album': album, 'year': year}
|
||||
return {
|
||||
'album': release.get('title'),
|
||||
'year': date[:4] if date else None,
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
class HistoryEntry(QFrame):
|
||||
def __init__(self, index: int, filepath: str, metadata: dict, parent=None):
|
||||
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("""
|
||||
@@ -75,51 +78,140 @@ class HistoryEntry(QFrame):
|
||||
}
|
||||
""")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(8, 6, 8, 6)
|
||||
layout.setSpacing(2)
|
||||
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_row = QLabel(f"{index}. {artist} — {title}")
|
||||
top_row.setFont(QFont("Arial", 11, QFont.Bold))
|
||||
top_row.setStyleSheet("color: #00ff41; background: transparent; border: none;")
|
||||
top_row.setWordWrap(True)
|
||||
layout.addWidget(top_row)
|
||||
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]
|
||||
|
||||
sub_parts = []
|
||||
if album:
|
||||
sub_parts.append(album)
|
||||
if year:
|
||||
sub_parts.append(year[:4])
|
||||
|
||||
if sub_parts:
|
||||
sub_row = QLabel(" " + " · ".join(sub_parts))
|
||||
sub_row.setFont(QFont("Arial", 9))
|
||||
sub_row.setStyleSheet(
|
||||
if parts:
|
||||
sub = QLabel(" " + " · ".join(parts))
|
||||
sub.setFont(QFont("Arial", 9))
|
||||
sub.setStyleSheet(
|
||||
"color: #888888; background: transparent; border: none;"
|
||||
)
|
||||
sub_row.setWordWrap(True)
|
||||
layout.addWidget(sub_row)
|
||||
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.setFont(QFont("Arial", 10, QFont.Bold))
|
||||
self._del_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #5a1a1a;
|
||||
color: #ff4444;
|
||||
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:
|
||||
import os
|
||||
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._history = []
|
||||
self._pending = {}
|
||||
self._entry_index = 0
|
||||
|
||||
self.setFrameStyle(QFrame.Box | QFrame.Raised)
|
||||
self.setStyleSheet("""
|
||||
@@ -138,21 +230,43 @@ class HistoryWidget(QFrame):
|
||||
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.setStyleSheet(
|
||||
"color: #336633; background: transparent; border: none; padding: 2px;"
|
||||
)
|
||||
header.setAlignment(Qt.AlignCenter)
|
||||
root.addWidget(header)
|
||||
|
||||
self.scroll = QScrollArea()
|
||||
self.scroll.setWidgetResizable(True)
|
||||
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.scroll.setStyleSheet("""
|
||||
# Toolbar
|
||||
bar = QHBoxLayout()
|
||||
bar.setSpacing(4)
|
||||
|
||||
self._add_btn = QPushButton("+ ADD")
|
||||
self._add_btn.setFixedHeight(28)
|
||||
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;
|
||||
@@ -168,18 +282,56 @@ class HistoryWidget(QFrame):
|
||||
}
|
||||
""")
|
||||
|
||||
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._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)
|
||||
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.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.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):
|
||||
"""Apply theme to history border."""
|
||||
self.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background-color: {theme['bg2']};
|
||||
@@ -189,41 +341,69 @@ class HistoryWidget(QFrame):
|
||||
""")
|
||||
|
||||
def add_track(self, filepath: str):
|
||||
index = len(self._history) + 1
|
||||
idx = self._entry_index + 1
|
||||
metadata = _read_file_tags(filepath)
|
||||
self._history.append((filepath, metadata))
|
||||
self._insert_entry(index, 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] = index
|
||||
self._pending[filepath] = idx
|
||||
threading.Thread(
|
||||
target=self._enrich_metadata,
|
||||
args=(filepath, metadata),
|
||||
daemon=True
|
||||
).start()
|
||||
|
||||
def _insert_entry(self, index: int, filepath: str, metadata: dict):
|
||||
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)
|
||||
self.list_layout.insertWidget(0, entry)
|
||||
self.scroll.verticalScrollBar().setValue(0)
|
||||
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):
|
||||
@@ -234,15 +414,57 @@ class HistoryWidget(QFrame):
|
||||
self._rebuild_list()
|
||||
|
||||
def _rebuild_list(self):
|
||||
while self.list_layout.count() > 1:
|
||||
item = self.list_layout.takeAt(0)
|
||||
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)
|
||||
self.list_layout.insertWidget(
|
||||
self.list_layout.count() - 1,
|
||||
entry
|
||||
)
|
||||
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])
|
||||
|
||||
Reference in New Issue
Block a user