Import existing LinTunes project
Snapshot of the existing codebase before working through the TASKS.md backlog. Real library data (data/) and the iTunes import fixture (itunes-test-library/) are gitignored.
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def config_path() -> Path:
|
||||
base = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
|
||||
return Path(base) / "lintunes" / "config.json"
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
path = config_path()
|
||||
if path.exists():
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def save_config(config: dict):
|
||||
path = config_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
@@ -0,0 +1,36 @@
|
||||
from PyQt6.QtWidgets import QWidget, QApplication
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtGui import QPainter, QPixmap
|
||||
|
||||
|
||||
class ArtWindow(QWidget):
|
||||
"""Non-modal window showing the current track's album art as large as
|
||||
possible. Just an album art window — closing it has no side effects."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent, Qt.WindowType.Window)
|
||||
self._pixmap: QPixmap | None = None
|
||||
self.setMinimumSize(120, 120)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False)
|
||||
|
||||
def set_artwork(self, pixmap: QPixmap | None, title: str):
|
||||
self.setWindowTitle(title or "Album Art")
|
||||
self._pixmap = pixmap
|
||||
if pixmap is not None and not self.isVisible():
|
||||
screen = QApplication.primaryScreen().availableGeometry()
|
||||
width = min(pixmap.width(), int(screen.width() * 0.8))
|
||||
height = min(pixmap.height(), int(screen.height() * 0.8))
|
||||
self.resize(max(width, 240), max(height, 240))
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.fillRect(self.rect(), Qt.GlobalColor.black)
|
||||
if self._pixmap is None or self._pixmap.isNull():
|
||||
return
|
||||
scaled = self._pixmap.scaled(
|
||||
self.size(), Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation)
|
||||
x = (self.width() - scaled.width()) // 2
|
||||
y = (self.height() - scaled.height()) // 2
|
||||
painter.drawPixmap(x, y, scaled)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""A cursor-following drag indicator that works on Wayland.
|
||||
|
||||
GNOME/Mutter does not render the translucent drag-icon surface produced by
|
||||
``QDrag.setPixmap()``, and the other native hooks (``setDragCursor``,
|
||||
``setOverrideCursor``) are unreliable during a drag too. So instead of asking
|
||||
the compositor to draw the drag image, we paint our own frameless overlay as a
|
||||
child of the top-level window and move it ourselves from the drag-move events
|
||||
Qt delivers to our drop targets (``QCursor.pos()`` is also unreliable on
|
||||
Wayland, so we never poll it).
|
||||
|
||||
The little song icon shown under the cursor is loaded from
|
||||
``packaging/drag-icon.png`` — the same folder as the application icon — so it
|
||||
can be swapped out and customized. If that file is missing we fall back to a
|
||||
hand-drawn page-and-note glyph so the feedback never silently disappears.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import Qt, QPoint, QPointF, QRectF
|
||||
from PyQt6.QtGui import QPixmap, QPainter, QColor, QPen, QPolygonF
|
||||
from PyQt6.QtWidgets import QWidget
|
||||
|
||||
# Sits next to packaging/lintunes.png (the app-icon source). parents[2] is the
|
||||
# repo root: lintunes/gui/drag_ghost.py -> gui -> lintunes -> <root>.
|
||||
ICON_PATH = Path(__file__).resolve().parents[2] / "packaging" / "drag-icon.png"
|
||||
|
||||
_base_cache: QPixmap | None = None
|
||||
|
||||
|
||||
def _base_pixmap() -> QPixmap:
|
||||
"""The full-resolution source icon (customizable PNG or drawn fallback)."""
|
||||
global _base_cache
|
||||
if _base_cache is None:
|
||||
pixmap = QPixmap(str(ICON_PATH))
|
||||
if pixmap.isNull():
|
||||
pixmap = _draw_fallback(256)
|
||||
_base_cache = pixmap
|
||||
return _base_cache
|
||||
|
||||
|
||||
def song_icon(size: int, ratio: float = 1.0) -> QPixmap:
|
||||
"""The drag icon scaled to ``size`` logical points for the given DPR."""
|
||||
px = max(1, round(size * ratio))
|
||||
scaled = _base_pixmap().scaled(
|
||||
px, px, Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation)
|
||||
scaled.setDevicePixelRatio(ratio)
|
||||
return scaled
|
||||
|
||||
|
||||
def _draw_fallback(size: int) -> QPixmap:
|
||||
"""A simple page-with-folded-corner and a music note, app-icon style."""
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
p = QPainter(pixmap)
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
s = size
|
||||
# Page body with a folded top-right corner.
|
||||
fold = s * 0.26
|
||||
body = QPolygonF([
|
||||
QPointF(s * 0.18, s * 0.10), QPointF(s * 0.66, s * 0.10),
|
||||
QPointF(s * 0.82, s * 0.10 + fold), QPointF(s * 0.82, s * 0.90),
|
||||
QPointF(s * 0.18, s * 0.90),
|
||||
])
|
||||
p.setPen(Qt.PenStyle.NoPen)
|
||||
p.setBrush(QColor("#f4f4f5"))
|
||||
p.drawPolygon(body)
|
||||
p.setBrush(QColor("#c8c8cc"))
|
||||
p.drawPolygon(QPolygonF([
|
||||
QPointF(s * 0.66, s * 0.10), QPointF(s * 0.66, s * 0.10 + fold),
|
||||
QPointF(s * 0.82, s * 0.10 + fold),
|
||||
]))
|
||||
# Music note: a filled head plus a stem.
|
||||
note = QColor("#2b6cb0")
|
||||
p.setBrush(note)
|
||||
p.drawEllipse(QPointF(s * 0.40, s * 0.70), s * 0.085, s * 0.065)
|
||||
pen = QPen(note, max(1.0, s * 0.045))
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
p.setPen(pen)
|
||||
p.drawLine(QPointF(s * 0.485, s * 0.685), QPointF(s * 0.485, s * 0.40))
|
||||
p.drawLine(QPointF(s * 0.485, s * 0.40), QPointF(s * 0.62, s * 0.46))
|
||||
p.end()
|
||||
return pixmap
|
||||
|
||||
|
||||
class _Ghost(QWidget):
|
||||
"""Frameless, click-through overlay that just paints a pixmap."""
|
||||
|
||||
def __init__(self, parent):
|
||||
super().__init__(parent)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
|
||||
self._pixmap = QPixmap()
|
||||
self._hotspot = QPoint(0, 0)
|
||||
|
||||
def set_content(self, pixmap: QPixmap, hotspot: QPoint):
|
||||
self._pixmap = pixmap
|
||||
self._hotspot = hotspot
|
||||
self.setFixedSize(pixmap.deviceIndependentSize().toSize())
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, _event):
|
||||
painter = QPainter(self)
|
||||
painter.drawPixmap(0, 0, self._pixmap)
|
||||
|
||||
|
||||
_ghost: _Ghost | None = None
|
||||
|
||||
|
||||
def begin(window, pixmap: QPixmap, hotspot: QPoint, global_pos: QPoint):
|
||||
"""Show the drag overlay on ``window`` (a top-level widget)."""
|
||||
global _ghost
|
||||
end()
|
||||
_ghost = _Ghost(window)
|
||||
_ghost.set_content(pixmap, hotspot)
|
||||
move(global_pos)
|
||||
_ghost.show()
|
||||
_ghost.raise_()
|
||||
|
||||
|
||||
def move(global_pos: QPoint):
|
||||
"""Reposition so the hotspot sits under the cursor. No-op when inactive."""
|
||||
if _ghost is None:
|
||||
return
|
||||
local = _ghost.parentWidget().mapFromGlobal(global_pos)
|
||||
_ghost.move(local - _ghost._hotspot)
|
||||
_ghost.raise_()
|
||||
|
||||
|
||||
def end():
|
||||
"""Tear down the overlay when the drag finishes."""
|
||||
global _ghost
|
||||
if _ghost is not None:
|
||||
_ghost.hide()
|
||||
_ghost.deleteLater()
|
||||
_ghost = None
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Hand-painted transport glyphs (prev/play/pause/next/shuffle) in any color,
|
||||
so buttons can flash the theme highlight while pressed."""
|
||||
|
||||
from PyQt6.QtCore import Qt, QPointF, QRectF
|
||||
from PyQt6.QtGui import QIcon, QPixmap, QPainter, QPen, QColor, QPolygonF
|
||||
|
||||
|
||||
_cache: dict[tuple, QIcon] = {}
|
||||
|
||||
SIZE = 20
|
||||
|
||||
|
||||
def transport_icon(kind: str, color: QColor) -> QIcon:
|
||||
key = (kind, color.name())
|
||||
if key in _cache:
|
||||
return _cache[key]
|
||||
pixmap = QPixmap(SIZE, SIZE)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(color)
|
||||
_DRAWERS[kind](painter, color)
|
||||
painter.end()
|
||||
icon = QIcon(pixmap)
|
||||
_cache[key] = icon
|
||||
return icon
|
||||
|
||||
|
||||
def _play(p, _color):
|
||||
p.drawPolygon(QPolygonF([QPointF(5, 3), QPointF(17, 10), QPointF(5, 17)]))
|
||||
|
||||
|
||||
def _pause(p, _color):
|
||||
p.drawRect(QRectF(5, 3.5, 3.6, 13))
|
||||
p.drawRect(QRectF(11.4, 3.5, 3.6, 13))
|
||||
|
||||
|
||||
def _next(p, _color):
|
||||
# Skip-to-next: two triangles plus a trailing bar (bare triangles = ffwd).
|
||||
p.drawPolygon(QPolygonF([QPointF(2, 4), QPointF(9, 10), QPointF(2, 16)]))
|
||||
p.drawPolygon(QPolygonF([QPointF(9, 4), QPointF(16, 10), QPointF(9, 16)]))
|
||||
p.drawRect(QRectF(16.5, 4, 2, 12))
|
||||
|
||||
|
||||
def _previous(p, _color):
|
||||
# Skip-to-previous: a leading bar plus two triangles (mirror of _next).
|
||||
p.drawRect(QRectF(1.5, 4, 2, 12))
|
||||
p.drawPolygon(QPolygonF([QPointF(11, 4), QPointF(4, 10), QPointF(11, 16)]))
|
||||
p.drawPolygon(QPolygonF([QPointF(18, 4), QPointF(11, 10), QPointF(18, 16)]))
|
||||
|
||||
|
||||
def _shuffle(p, color):
|
||||
pen = QPen(color, 1.8)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
p.setPen(pen)
|
||||
p.setBrush(Qt.BrushStyle.NoBrush)
|
||||
# Two crossing strands
|
||||
p.drawLine(QPointF(2, 6), QPointF(6, 6))
|
||||
p.drawLine(QPointF(6, 6), QPointF(13, 14))
|
||||
p.drawLine(QPointF(13, 14), QPointF(16, 14))
|
||||
p.drawLine(QPointF(2, 14), QPointF(6, 14))
|
||||
p.drawLine(QPointF(6, 14), QPointF(13, 6))
|
||||
p.drawLine(QPointF(13, 6), QPointF(16, 6))
|
||||
# Arrowheads
|
||||
p.setPen(Qt.PenStyle.NoPen)
|
||||
p.setBrush(color)
|
||||
p.drawPolygon(QPolygonF([QPointF(15, 3), QPointF(19, 6), QPointF(15, 9)]))
|
||||
p.drawPolygon(QPolygonF([QPointF(15, 11), QPointF(19, 14), QPointF(15, 17)]))
|
||||
|
||||
|
||||
_DRAWERS = {
|
||||
"play": _play,
|
||||
"pause": _pause,
|
||||
"next": _next,
|
||||
"previous": _previous,
|
||||
"shuffle": _shuffle,
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QDialogButtonBox, QFormLayout, QLineEdit, QSpinBox, QCheckBox,
|
||||
QPlainTextEdit, QHBoxLayout, QLabel, QVBoxLayout, QPushButton, QMessageBox,
|
||||
QApplication, QMenu, QFileDialog, QWidget,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QBuffer, QIODevice, pyqtSignal
|
||||
from PyQt6.QtGui import QPixmap, QKeySequence
|
||||
|
||||
from lintunes import tagging
|
||||
|
||||
|
||||
_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
|
||||
|
||||
|
||||
class ArtSquare(QLabel):
|
||||
"""Clickable cover-art square: click to focus, then paste (Ctrl+V) or
|
||||
drop an image file to stage new artwork."""
|
||||
|
||||
art_staged = pyqtSignal(bytes, str) # image bytes, mime
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setFixedSize(120, 120)
|
||||
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.ClickFocus)
|
||||
self.setAcceptDrops(True)
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(self._show_menu)
|
||||
self.set_artwork(None)
|
||||
|
||||
def set_artwork(self, image_bytes: bytes | None):
|
||||
if image_bytes:
|
||||
pixmap = QPixmap()
|
||||
if pixmap.loadFromData(image_bytes):
|
||||
self.setPixmap(pixmap.scaled(
|
||||
self.size(), Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation))
|
||||
self.setStyleSheet(
|
||||
"ArtSquare { border: 1px solid palette(mid); }"
|
||||
"ArtSquare:focus { border: 2px solid palette(highlight); }")
|
||||
return
|
||||
self.setPixmap(QPixmap())
|
||||
self.setText("no artwork\n\nclick, then paste\nan image")
|
||||
self.setStyleSheet(
|
||||
"ArtSquare { border: 2px dashed palette(mid); color: palette(mid); }"
|
||||
"ArtSquare:focus { border: 2px dashed palette(highlight); }")
|
||||
|
||||
# ---- staging from clipboard / files ----
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
if event.matches(QKeySequence.StandardKey.Paste):
|
||||
self.paste_from_clipboard()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def paste_from_clipboard(self):
|
||||
mime = QApplication.clipboard().mimeData()
|
||||
if mime is None:
|
||||
return
|
||||
# Prefer raw image file bytes (keeps original compression)
|
||||
for fmt in ("image/jpeg", "image/png"):
|
||||
if mime.hasFormat(fmt):
|
||||
self._stage(bytes(mime.data(fmt)), fmt)
|
||||
return
|
||||
if mime.hasImage():
|
||||
image = QApplication.clipboard().image()
|
||||
if not image.isNull():
|
||||
buffer = QBuffer()
|
||||
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
image.save(buffer, "JPEG", quality=92)
|
||||
self._stage(bytes(buffer.data()), "image/jpeg")
|
||||
return
|
||||
if mime.hasUrls():
|
||||
for url in mime.urls():
|
||||
if url.isLocalFile() and self._stage_file(Path(url.toLocalFile())):
|
||||
return
|
||||
|
||||
def _stage_file(self, path: Path) -> bool:
|
||||
if path.suffix.lower() not in _IMAGE_EXTENSIONS or not path.is_file():
|
||||
return False
|
||||
mime = "image/png" if path.suffix.lower() == ".png" else "image/jpeg"
|
||||
if path.suffix.lower() not in (".jpg", ".jpeg", ".png"):
|
||||
# Recode exotic formats to JPEG so all tag formats accept them
|
||||
pixmap = QPixmap(str(path))
|
||||
if pixmap.isNull():
|
||||
return False
|
||||
buffer = QBuffer()
|
||||
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
pixmap.save(buffer, "JPEG", quality=92)
|
||||
self._stage(bytes(buffer.data()), "image/jpeg")
|
||||
return True
|
||||
self._stage(path.read_bytes(), mime)
|
||||
return True
|
||||
|
||||
def _stage(self, image_bytes: bytes, mime: str):
|
||||
self.set_artwork(image_bytes)
|
||||
self.art_staged.emit(image_bytes, mime)
|
||||
|
||||
def _show_menu(self, pos):
|
||||
menu = QMenu(self)
|
||||
paste = menu.addAction("Paste Image\tCtrl+V")
|
||||
choose = menu.addAction("Choose File…")
|
||||
chosen = menu.exec(self.mapToGlobal(pos))
|
||||
if chosen is paste:
|
||||
self.paste_from_clipboard()
|
||||
elif chosen is choose:
|
||||
file_name, _filter = QFileDialog.getOpenFileName(
|
||||
self, "Choose Artwork", "",
|
||||
"Images (*.jpg *.jpeg *.png *.gif *.bmp *.webp)")
|
||||
if file_name:
|
||||
self._stage_file(Path(file_name))
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
if event.mimeData().hasUrls() or event.mimeData().hasImage():
|
||||
event.acceptProposedAction()
|
||||
|
||||
def dropEvent(self, event):
|
||||
for url in event.mimeData().urls():
|
||||
if url.isLocalFile() and self._stage_file(Path(url.toLocalFile())):
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
|
||||
|
||||
_TEXT_FIELDS = [
|
||||
("name", "Name"),
|
||||
("artist", "Artist"),
|
||||
("album_artist", "Album Artist"),
|
||||
("album", "Album"),
|
||||
("genre", "Genre"),
|
||||
("composer", "Composer"),
|
||||
("grouping", "Grouping"),
|
||||
]
|
||||
|
||||
_NUMBER_FIELDS = [
|
||||
("year", "Year", 9999),
|
||||
("track_number", "Track Number", 999),
|
||||
("track_count", "of (tracks)", 999),
|
||||
("disc_number", "Disc Number", 99),
|
||||
("disc_count", "of (discs)", 99),
|
||||
("bpm", "BPM", 999),
|
||||
]
|
||||
|
||||
# Distinguishes "every selected track shares this value" from "the tracks
|
||||
# disagree" when editing several at once.
|
||||
_MIXED = object()
|
||||
|
||||
|
||||
def _format_ms(ms: int) -> str:
|
||||
"""Render milliseconds as iTunes-style m:ss(.mmm) (millis only when set)."""
|
||||
if ms <= 0:
|
||||
return ""
|
||||
mins, rem = divmod(ms, 60_000)
|
||||
secs = rem / 1000
|
||||
if rem % 1000 == 0:
|
||||
return f"{mins}:{int(secs):02d}"
|
||||
return f"{mins}:{secs:06.3f}"
|
||||
|
||||
|
||||
def _parse_time_to_ms(text: str):
|
||||
"""Parse m:ss(.mmm) / h:mm:ss / plain seconds to ms; None if unparseable."""
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return 0
|
||||
try:
|
||||
parts = [float(p) for p in text.split(":")]
|
||||
except ValueError:
|
||||
return None
|
||||
seconds = 0.0
|
||||
for part in parts:
|
||||
seconds = seconds * 60 + part
|
||||
return int(round(seconds * 1000))
|
||||
|
||||
|
||||
class _TimeField(QLineEdit):
|
||||
"""Small text field that edits a duration in milliseconds as m:ss.mmm."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setFixedWidth(90)
|
||||
self.setPlaceholderText("0:00")
|
||||
|
||||
def set_ms(self, ms: int):
|
||||
self.setText(_format_ms(ms))
|
||||
|
||||
def ms(self) -> int:
|
||||
value = _parse_time_to_ms(self.text())
|
||||
return value if value is not None else 0
|
||||
|
||||
|
||||
class InfoDialog(QDialog):
|
||||
"""iTunes-style Get Info: edits library fields and the file's real tags.
|
||||
|
||||
Single track: Previous/Next walk a multi-selection one at a time, and the
|
||||
Options block exposes custom start/stop times. Multiple tracks: a combined
|
||||
editor where fields shared by every selection are editable and applied to
|
||||
all, while fields that differ show "Mixed" and are only written if touched.
|
||||
"""
|
||||
|
||||
def __init__(self, manager, track_ids: list[int], parent=None):
|
||||
super().__init__(parent)
|
||||
self._manager = manager
|
||||
self._track_ids = [tid for tid in track_ids
|
||||
if tid in manager.library.tracks]
|
||||
self._index = 0
|
||||
self._multi = len(self._track_ids) > 1
|
||||
# In multi mode, only fields the user actually edits get written to
|
||||
# every track (so an untouched "Mixed" field never overwrites anything).
|
||||
self._touched: set[str] = set()
|
||||
self._loading = False
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
top = QHBoxLayout()
|
||||
layout.addLayout(top)
|
||||
form = QFormLayout()
|
||||
top.addLayout(form, stretch=1)
|
||||
|
||||
art_col = QVBoxLayout()
|
||||
self._art_square = ArtSquare()
|
||||
self._art_square.art_staged.connect(self._on_art_staged)
|
||||
art_col.addWidget(self._art_square)
|
||||
art_col.addStretch()
|
||||
top.addLayout(art_col)
|
||||
self._staged_art: tuple[bytes, str] | None = None
|
||||
|
||||
self._editors = {}
|
||||
for field, label in _TEXT_FIELDS:
|
||||
editor = QLineEdit()
|
||||
# textEdited fires only on user input, so loading values never
|
||||
# marks a field touched.
|
||||
editor.textEdited.connect(lambda _t, f=field: self._mark_touched(f))
|
||||
self._editors[field] = editor
|
||||
form.addRow(label + ":", editor)
|
||||
|
||||
for field, label, maximum in _NUMBER_FIELDS:
|
||||
editor = QSpinBox()
|
||||
editor.setRange(0, maximum)
|
||||
editor.setSpecialValueText(" ")
|
||||
editor.valueChanged.connect(lambda _v, f=field: self._mark_touched(f))
|
||||
self._editors[field] = editor
|
||||
form.addRow(label + ":", editor)
|
||||
|
||||
self._compilation = QCheckBox("Album is a compilation of songs by various artists")
|
||||
self._compilation.clicked.connect(lambda: self._mark_touched("compilation"))
|
||||
form.addRow("", self._compilation)
|
||||
|
||||
self._comments = QPlainTextEdit()
|
||||
self._comments.setFixedHeight(60)
|
||||
self._comments.textChanged.connect(lambda: self._mark_touched("comments"))
|
||||
form.addRow("Comments:", self._comments)
|
||||
|
||||
# Custom start/stop times (single-track only; one absolute ms value
|
||||
# across different-length tracks isn't meaningful).
|
||||
self._start_row, self._start_check, self._start_time = \
|
||||
self._make_time_row("Start time")
|
||||
self._stop_row, self._stop_check, self._stop_time = \
|
||||
self._make_time_row("Stop time")
|
||||
form.addRow("", self._start_row)
|
||||
form.addRow("", self._stop_row)
|
||||
|
||||
self._file_label = QLabel()
|
||||
self._file_label.setStyleSheet("color: gray; font-size: 10px;")
|
||||
self._file_label.setWordWrap(True)
|
||||
layout.addWidget(self._file_label)
|
||||
|
||||
nav_row = QHBoxLayout()
|
||||
self._prev_btn = QPushButton("Previous")
|
||||
self._next_btn = QPushButton("Next")
|
||||
nav_row.addWidget(self._prev_btn)
|
||||
nav_row.addWidget(self._next_btn)
|
||||
nav_row.addStretch()
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok
|
||||
| QDialogButtonBox.StandardButton.Cancel)
|
||||
nav_row.addWidget(buttons)
|
||||
layout.addLayout(nav_row)
|
||||
|
||||
self._prev_btn.clicked.connect(lambda: self._navigate(-1))
|
||||
self._next_btn.clicked.connect(lambda: self._navigate(1))
|
||||
buttons.accepted.connect(self._on_ok)
|
||||
buttons.rejected.connect(self.reject)
|
||||
|
||||
if self._multi:
|
||||
# Batch editing: no single file, no per-track art/times navigation.
|
||||
self._art_square.setVisible(False)
|
||||
self._start_row.setVisible(False)
|
||||
self._stop_row.setVisible(False)
|
||||
self._file_label.setVisible(False)
|
||||
self._prev_btn.setVisible(False)
|
||||
self._next_btn.setVisible(False)
|
||||
self._load_multi()
|
||||
else:
|
||||
self._load_current()
|
||||
|
||||
def _make_time_row(self, title: str):
|
||||
row = QWidget()
|
||||
h = QHBoxLayout(row)
|
||||
h.setContentsMargins(0, 0, 0, 0)
|
||||
check = QCheckBox(title)
|
||||
field = _TimeField()
|
||||
field.setEnabled(False)
|
||||
check.toggled.connect(field.setEnabled)
|
||||
h.addWidget(check)
|
||||
h.addWidget(field)
|
||||
h.addStretch()
|
||||
return row, check, field
|
||||
|
||||
def _mark_touched(self, field: str):
|
||||
if not self._loading:
|
||||
self._touched.add(field)
|
||||
|
||||
def _current_track(self):
|
||||
return self._manager.library.tracks[self._track_ids[self._index]]
|
||||
|
||||
def _common_value(self, field: str):
|
||||
"""The value shared by every selected track, else _MIXED."""
|
||||
tracks = self._manager.library.tracks
|
||||
values = [getattr(tracks[tid], field) for tid in self._track_ids]
|
||||
first = values[0]
|
||||
return first if all(v == first for v in values) else _MIXED
|
||||
|
||||
def _on_art_staged(self, image_bytes: bytes, mime: str):
|
||||
self._staged_art = (bytes(image_bytes), mime)
|
||||
|
||||
def _load_current(self):
|
||||
track = self._current_track()
|
||||
self._loading = True
|
||||
self.setWindowTitle(f"{track.name} — Info")
|
||||
self._staged_art = None
|
||||
existing_art = (tagging.read_embedded_artwork(track.location)
|
||||
if track.location else None)
|
||||
self._art_square.set_artwork(existing_art)
|
||||
for field, _label in _TEXT_FIELDS:
|
||||
self._editors[field].setText(getattr(track, field) or "")
|
||||
for field, _label, _max in _NUMBER_FIELDS:
|
||||
self._editors[field].setValue(getattr(track, field) or 0)
|
||||
self._compilation.setChecked(track.compilation)
|
||||
self._comments.setPlainText(track.comments or "")
|
||||
self._start_check.setChecked(track.start_time > 0)
|
||||
self._start_time.setEnabled(track.start_time > 0)
|
||||
self._start_time.set_ms(track.start_time)
|
||||
self._stop_check.setChecked(track.stop_time > 0)
|
||||
self._stop_time.setEnabled(track.stop_time > 0)
|
||||
self._stop_time.set_ms(track.stop_time)
|
||||
self._file_label.setText(track.location or "")
|
||||
self._prev_btn.setEnabled(self._index > 0)
|
||||
self._next_btn.setEnabled(self._index < len(self._track_ids) - 1)
|
||||
self._loading = False
|
||||
|
||||
def _load_multi(self):
|
||||
self._loading = True
|
||||
self.setWindowTitle(f"{len(self._track_ids)} Items — Info")
|
||||
for field, _label in _TEXT_FIELDS:
|
||||
common = self._common_value(field)
|
||||
editor = self._editors[field]
|
||||
if common is _MIXED:
|
||||
editor.clear()
|
||||
editor.setPlaceholderText("Mixed")
|
||||
else:
|
||||
editor.setText(common or "")
|
||||
for field, _label, _max in _NUMBER_FIELDS:
|
||||
common = self._common_value(field)
|
||||
editor = self._editors[field]
|
||||
if common is _MIXED:
|
||||
editor.setSpecialValueText("Mixed")
|
||||
editor.setValue(0)
|
||||
else:
|
||||
editor.setValue(common or 0)
|
||||
comp = self._common_value("compilation")
|
||||
self._compilation.setChecked(False if comp is _MIXED else bool(comp))
|
||||
comments = self._common_value("comments")
|
||||
if comments is _MIXED:
|
||||
self._comments.setPlaceholderText("Mixed")
|
||||
self._comments.setPlainText("")
|
||||
else:
|
||||
self._comments.setPlainText(comments or "")
|
||||
self._loading = False
|
||||
|
||||
def _collect_fields(self) -> dict:
|
||||
fields = {}
|
||||
for field, _label in _TEXT_FIELDS:
|
||||
fields[field] = self._editors[field].text().strip()
|
||||
for field, _label, _max in _NUMBER_FIELDS:
|
||||
fields[field] = self._editors[field].value()
|
||||
fields["compilation"] = self._compilation.isChecked()
|
||||
fields["comments"] = self._comments.toPlainText().strip()
|
||||
fields["start_time"] = (self._start_time.ms()
|
||||
if self._start_check.isChecked() else 0)
|
||||
fields["stop_time"] = (self._stop_time.ms()
|
||||
if self._stop_check.isChecked() else 0)
|
||||
return fields
|
||||
|
||||
def _validate_times(self, track) -> str | None:
|
||||
start = self._start_time.ms() if self._start_check.isChecked() else 0
|
||||
stop = self._stop_time.ms() if self._stop_check.isChecked() else 0
|
||||
if start and track.total_time and start >= track.total_time:
|
||||
return "The start time is past the end of the track."
|
||||
if start and stop and stop <= start:
|
||||
return "The stop time must be after the start time."
|
||||
return None
|
||||
|
||||
def _save_current(self) -> bool:
|
||||
track = self._current_track()
|
||||
error = self._validate_times(track)
|
||||
if error:
|
||||
QMessageBox.warning(self, "Invalid times", error)
|
||||
return False
|
||||
fields = self._collect_fields()
|
||||
fields_changed = any(getattr(track, k) != v for k, v in fields.items())
|
||||
if not fields_changed and self._staged_art is None:
|
||||
return True
|
||||
if track.location:
|
||||
try:
|
||||
if self._staged_art is not None:
|
||||
tagging.write_artwork(track.location, *self._staged_art)
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "Tag write failed",
|
||||
f"Could not write tags to file:\n{e}")
|
||||
return False
|
||||
if self._staged_art is not None:
|
||||
# Embedding art changed the file; keep the library's size honest
|
||||
# (and trigger track_updated so the transport art refreshes)
|
||||
fields["size"] = Path(track.location).stat().st_size
|
||||
# Field edits (incl. the post-art size bump) go through the manager,
|
||||
# which writes the file tags and records the change for undo. Artwork
|
||||
# itself is written above and is intentionally not undoable.
|
||||
self._manager.edit_track_fields(track.track_id, fields)
|
||||
self._staged_art = None
|
||||
return True
|
||||
|
||||
def _save_multi(self) -> bool:
|
||||
fields = {}
|
||||
for field, _label in _TEXT_FIELDS:
|
||||
if field in self._touched:
|
||||
fields[field] = self._editors[field].text().strip()
|
||||
for field, _label, _max in _NUMBER_FIELDS:
|
||||
if field in self._touched:
|
||||
fields[field] = self._editors[field].value()
|
||||
if "compilation" in self._touched:
|
||||
fields["compilation"] = self._compilation.isChecked()
|
||||
if "comments" in self._touched:
|
||||
fields["comments"] = self._comments.toPlainText().strip()
|
||||
if fields:
|
||||
self._manager.edit_tracks_fields(self._track_ids, fields)
|
||||
return True
|
||||
|
||||
def _navigate(self, delta: int):
|
||||
if not self._save_current():
|
||||
return
|
||||
self._index = max(0, min(self._index + delta, len(self._track_ids) - 1))
|
||||
self._load_current()
|
||||
|
||||
def _on_ok(self):
|
||||
saved = self._save_multi() if self._multi else self._save_current()
|
||||
if saved:
|
||||
self.accept()
|
||||
@@ -0,0 +1,305 @@
|
||||
import re
|
||||
from collections import Counter
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QSplitter, QLineEdit,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
|
||||
|
||||
from lintunes.gui.track_table import TrackTableView
|
||||
|
||||
|
||||
_ARTICLE_RE = re.compile(r"^the\s+", re.IGNORECASE)
|
||||
|
||||
# Pause this long after the last keystroke before re-filtering, so typing a
|
||||
# query doesn't scan the whole library on every keypress.
|
||||
_SEARCH_DEBOUNCE_MS = 250
|
||||
|
||||
# Every track field the search box matches against (substring, case-folded).
|
||||
_SEARCH_FIELDS = ("name", "artist", "album_artist", "album", "genre",
|
||||
"composer", "grouping", "comments", "year", "kind")
|
||||
|
||||
|
||||
def _matches_search(track, tokens) -> bool:
|
||||
"""True when every token appears (case-folded substring) in some searched
|
||||
field of the track. An empty token list matches everything."""
|
||||
haystack = " ".join(str(getattr(track, f) or "")
|
||||
for f in _SEARCH_FIELDS).casefold()
|
||||
return all(token.casefold() in haystack for token in tokens)
|
||||
|
||||
# Editing one of these reorganizes the genre/artist/album browser, so the
|
||||
# lists must be repopulated and the table re-filtered (a plain row repaint
|
||||
# would leave the renamed value at its old spot and filtering by the old name).
|
||||
_BROWSER_FIELDS = {"genre", "artist", "album_artist", "album"}
|
||||
|
||||
|
||||
def _artist_sort_key(name: str) -> str:
|
||||
"""Sort artists ignoring a leading 'The' (and case): 'The Beatles' files
|
||||
next to 'Beat Happening'."""
|
||||
return _ARTICLE_RE.sub("", name).casefold()
|
||||
|
||||
|
||||
def _canonical_values(values) -> list[str]:
|
||||
"""Collapse case variants of the same string. For each case-insensitive
|
||||
group, return the spelling used by the most tracks (ties: more capital
|
||||
letters, then alphabetical)."""
|
||||
groups: dict[str, Counter] = {}
|
||||
for value in values:
|
||||
groups.setdefault(value.casefold(), Counter())[value] += 1
|
||||
result = []
|
||||
for counter in groups.values():
|
||||
best = sorted(
|
||||
counter.items(),
|
||||
key=lambda kv: (-kv[1], -sum(c.isupper() for c in kv[0]), kv[0]),
|
||||
)[0][0]
|
||||
result.append(best)
|
||||
return result
|
||||
|
||||
|
||||
class _BrowserList(QListWidget):
|
||||
"""One column of the genre/artist/album browser."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setMinimumWidth(120)
|
||||
self.setUniformItemSizes(True)
|
||||
|
||||
def set_values(self, label: str, values: list[str]):
|
||||
current = self.selected_value()
|
||||
self.blockSignals(True)
|
||||
self.clear()
|
||||
self.addItem(f"All ({len(values)} {label})")
|
||||
self.addItems(values)
|
||||
# Keep the previous selection if it survived the refilter
|
||||
row = 0
|
||||
if current:
|
||||
matches = self.findItems(current, Qt.MatchFlag.MatchExactly)
|
||||
if matches:
|
||||
row = self.row(matches[0])
|
||||
self.setCurrentRow(row)
|
||||
self.blockSignals(False)
|
||||
|
||||
def selected_value(self) -> str:
|
||||
item = self.currentItem()
|
||||
if item is None or self.row(item) == 0:
|
||||
return ""
|
||||
return item.text()
|
||||
|
||||
|
||||
class LibraryView(QWidget):
|
||||
"""All-tracks view with the iTunes-style 3-column browser (Ctrl+B)."""
|
||||
|
||||
play_requested = pyqtSignal(list, int)
|
||||
info_requested = pyqtSignal(list)
|
||||
show_in_playlist_requested = pyqtSignal(int, str) # track_id, playlist pid
|
||||
|
||||
def __init__(self, manager, parent=None):
|
||||
super().__init__(parent)
|
||||
self._manager = manager
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Empty strip matching the sidebar's Library button / playlist name
|
||||
# band, so the browser+tracklist top aligns with the playlist tree.
|
||||
self._top_strip = QWidget()
|
||||
layout.addWidget(self._top_strip)
|
||||
|
||||
# Search row: a debounced filter box occupying ~1/3 of the width, top
|
||||
# right (iTunes-style). It narrows the track list and the browser
|
||||
# columns together.
|
||||
self._search_text = ""
|
||||
self._search = QLineEdit()
|
||||
self._search.setPlaceholderText("search")
|
||||
self._search.setClearButtonEnabled(True)
|
||||
search_row = QHBoxLayout()
|
||||
search_row.setContentsMargins(4, 2, 4, 2)
|
||||
search_row.addStretch(2)
|
||||
search_row.addWidget(self._search, 1)
|
||||
layout.addLayout(search_row)
|
||||
|
||||
self._search_timer = QTimer(self)
|
||||
self._search_timer.setSingleShot(True)
|
||||
self._search_timer.setInterval(_SEARCH_DEBOUNCE_MS)
|
||||
self._search_timer.timeout.connect(self._apply_search)
|
||||
self._search.textChanged.connect(lambda _t: self._search_timer.start())
|
||||
|
||||
self._browser = QSplitter(Qt.Orientation.Horizontal)
|
||||
self._genre_list = _BrowserList()
|
||||
self._artist_list = _BrowserList()
|
||||
self._album_list = _BrowserList()
|
||||
for lst in (self._genre_list, self._artist_list, self._album_list):
|
||||
self._browser.addWidget(lst)
|
||||
|
||||
self.table = TrackTableView(playlist_mode=False)
|
||||
|
||||
# Vertical splitter so the browser/track-list boundary is draggable
|
||||
self._vsplit = QSplitter(Qt.Orientation.Vertical)
|
||||
self._vsplit.addWidget(self._browser)
|
||||
self._vsplit.addWidget(self.table)
|
||||
self._vsplit.setStretchFactor(1, 1)
|
||||
self._vsplit.setCollapsible(1, False)
|
||||
self._vsplit.setSizes([200, 600])
|
||||
layout.addWidget(self._vsplit)
|
||||
|
||||
self._genre_list.currentItemChanged.connect(self._on_genre_changed)
|
||||
self._artist_list.currentItemChanged.connect(self._on_artist_changed)
|
||||
self._album_list.currentItemChanged.connect(self._on_album_changed)
|
||||
|
||||
self.table.playlists_for_track = manager.playlists_containing
|
||||
self.table.play_requested.connect(self.play_requested)
|
||||
self.table.info_requested.connect(self.info_requested)
|
||||
self.table.show_in_playlist_requested.connect(
|
||||
self.show_in_playlist_requested)
|
||||
self.table.sort_changed.connect(self._on_sort_changed)
|
||||
self.table.columns_changed.connect(self._on_columns_changed)
|
||||
self.table.column_width_changed.connect(self._on_width_changed)
|
||||
|
||||
manager.track_updated.connect(self.table.model_.refresh_track)
|
||||
manager.track_fields_edited.connect(self._on_track_fields_edited)
|
||||
|
||||
self.reload()
|
||||
|
||||
def set_header_height(self, height: int):
|
||||
# Kept in sync with the sidebar Library button and the playlist view's
|
||||
# name strip so the tracklist tops line up across views.
|
||||
self._top_strip.setFixedHeight(height)
|
||||
|
||||
# ---- settings persistence ----
|
||||
|
||||
@property
|
||||
def _settings(self):
|
||||
return self._manager.library.library_settings
|
||||
|
||||
def _on_sort_changed(self, field, ascending):
|
||||
self._settings.sort_column = field
|
||||
self._settings.sort_ascending = ascending
|
||||
self._manager.mark_library_settings_dirty()
|
||||
|
||||
def _on_columns_changed(self, columns):
|
||||
self._settings.visible_columns = columns
|
||||
self.table.apply_settings(self._settings)
|
||||
self._manager.mark_library_settings_dirty()
|
||||
|
||||
def _on_width_changed(self, field, width):
|
||||
self._settings.column_widths[field] = width
|
||||
self._manager.mark_library_settings_dirty()
|
||||
|
||||
# ---- browser ----
|
||||
|
||||
def toggle_browser(self):
|
||||
self._browser.setVisible(not self._browser.isVisible())
|
||||
|
||||
def reveal_track(self, track_id: int):
|
||||
"""Scroll to and select a track. If a search or the browser filters
|
||||
hide it, clear them to the full library first so the song is always
|
||||
reachable."""
|
||||
if self.table.reveal_track(track_id):
|
||||
return
|
||||
self._search.blockSignals(True)
|
||||
self._search.clear()
|
||||
self._search.blockSignals(False)
|
||||
self._search_text = ""
|
||||
self._search_tracks = self._all_tracks
|
||||
for lst in (self._genre_list, self._artist_list, self._album_list):
|
||||
lst.blockSignals(True)
|
||||
lst.setCurrentRow(0) # "All"
|
||||
lst.blockSignals(False)
|
||||
self._populate_genres()
|
||||
self._populate_artists()
|
||||
self._populate_albums()
|
||||
self._update_track_table()
|
||||
self.table.reveal_track(track_id)
|
||||
|
||||
def reload(self):
|
||||
"""Rebuild everything from the library (after import or track add)."""
|
||||
self._all_tracks = list(self._manager.library.tracks.values())
|
||||
self._search_tracks = self._compute_search_tracks()
|
||||
self.table.apply_settings(self._settings)
|
||||
self._populate_genres()
|
||||
self._populate_artists()
|
||||
self._populate_albums()
|
||||
self._update_track_table()
|
||||
|
||||
# ---- search ----
|
||||
|
||||
def _compute_search_tracks(self) -> list:
|
||||
"""The subset of the library matching the current search (all tokens
|
||||
must appear, as case-folded substrings, in some searched field)."""
|
||||
if not self._search_text:
|
||||
return self._all_tracks
|
||||
tokens = self._search_text.split()
|
||||
return [t for t in self._all_tracks if _matches_search(t, tokens)]
|
||||
|
||||
def _apply_search(self):
|
||||
self._search_text = self._search.text().strip().casefold()
|
||||
self._search_tracks = self._compute_search_tracks()
|
||||
# Rebuild the whole cascade so the genre/artist/album columns reflect
|
||||
# only what the search matched.
|
||||
self._populate_genres()
|
||||
self._populate_artists()
|
||||
self._populate_albums()
|
||||
self._update_track_table()
|
||||
|
||||
def _filtered_tracks(self, up_to: str) -> list:
|
||||
# Compare casefolded: the browser shows one canonical spelling per
|
||||
# group, but tracks may be tagged with any case variant. The cascade
|
||||
# starts from the search-matched set so the browser stays in sync.
|
||||
tracks = self._search_tracks
|
||||
genre = self._genre_list.selected_value().casefold()
|
||||
if genre:
|
||||
tracks = [t for t in tracks if t.genre.casefold() == genre]
|
||||
if up_to in ("artist", "album"):
|
||||
artist = self._artist_list.selected_value().casefold()
|
||||
if artist:
|
||||
tracks = [t for t in tracks
|
||||
if (t.album_artist or t.artist).casefold() == artist]
|
||||
if up_to == "album":
|
||||
album = self._album_list.selected_value().casefold()
|
||||
if album:
|
||||
tracks = [t for t in tracks if t.album.casefold() == album]
|
||||
return tracks
|
||||
|
||||
def _populate_genres(self):
|
||||
genres = _canonical_values(t.genre for t in self._search_tracks if t.genre)
|
||||
self._genre_list.set_values("Genres", sorted(genres, key=str.casefold))
|
||||
|
||||
def _populate_artists(self):
|
||||
tracks = self._filtered_tracks(up_to="genre")
|
||||
artists = _canonical_values((t.album_artist or t.artist) for t in tracks
|
||||
if t.album_artist or t.artist)
|
||||
self._artist_list.set_values("Artists",
|
||||
sorted(artists, key=_artist_sort_key))
|
||||
|
||||
def _populate_albums(self):
|
||||
tracks = self._filtered_tracks(up_to="artist")
|
||||
albums = _canonical_values(t.album for t in tracks if t.album)
|
||||
self._album_list.set_values("Albums", sorted(albums, key=str.casefold))
|
||||
|
||||
def _update_track_table(self):
|
||||
self.table.set_tracks(self._filtered_tracks(up_to="album"))
|
||||
|
||||
def _on_genre_changed(self):
|
||||
self._populate_artists()
|
||||
self._populate_albums()
|
||||
self._update_track_table()
|
||||
|
||||
def _on_artist_changed(self):
|
||||
self._populate_albums()
|
||||
self._update_track_table()
|
||||
|
||||
def _on_album_changed(self):
|
||||
self._update_track_table()
|
||||
|
||||
def _on_track_fields_edited(self, track_id: int, changed_fields: list):
|
||||
"""A Get-Info edit landed. If it touched a field the browser groups by,
|
||||
rebuild the lists and re-filter so the renamed value sorts to its new
|
||||
spot and clicking it still finds its tracks (the plain row repaint from
|
||||
track_updated leaves the browser stale). Non-browser edits (name, bpm…)
|
||||
are handled by the proxy's live re-sort, so they need nothing here."""
|
||||
if _BROWSER_FIELDS.intersection(changed_fields):
|
||||
self._populate_genres()
|
||||
self._populate_artists()
|
||||
self._populate_albums()
|
||||
self._update_track_table()
|
||||
@@ -0,0 +1,405 @@
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QMainWindow, QSplitter, QStackedWidget, QWidget, QVBoxLayout, QLineEdit,
|
||||
QPlainTextEdit, QTextEdit, QAbstractSpinBox, QComboBox, QApplication,
|
||||
QLabel, QMessageBox, QFileDialog,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QEvent, QTimer
|
||||
from PyQt6.QtGui import QAction, QKeySequence
|
||||
|
||||
from lintunes import theme
|
||||
from lintunes.inhibit import SleepInhibitor
|
||||
from lintunes.player import Player
|
||||
from lintunes.importers import file_importer
|
||||
from lintunes.gui.sidebar import SidebarPanel
|
||||
from lintunes.gui.library_view import LibraryView
|
||||
from lintunes.gui.playlist_view import PlaylistView
|
||||
from lintunes.gui.transport import TransportBar
|
||||
from lintunes.gui.info_dialog import InfoDialog
|
||||
from lintunes.gui.preferences_dialog import PreferencesDialog
|
||||
from lintunes.gui.track_table import format_total_time
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self, manager, prefs, lastfm=None, parent=None):
|
||||
super().__init__(parent)
|
||||
self._manager = manager
|
||||
self._prefs = prefs
|
||||
self._lastfm = lastfm
|
||||
self._prefs_dialog = None
|
||||
self.player = Player(manager, self)
|
||||
# Keep the machine awake while audio is actually playing.
|
||||
self._inhibitor = SleepInhibitor()
|
||||
# Context playback started from: "library" or "playlist:<pid>". Scopes
|
||||
# the now-playing speaker icon to where the song is actually playing.
|
||||
self._now_playing_context = ""
|
||||
|
||||
self.setWindowTitle("LinTunes")
|
||||
self.resize(1280, 800)
|
||||
# Keep the window's minimum well under typical sizes so the WM never
|
||||
# refuses an interactive shrink
|
||||
self.setMinimumSize(560, 420)
|
||||
self.setAcceptDrops(True)
|
||||
|
||||
central = QWidget()
|
||||
layout = QVBoxLayout(central)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
self.setCentralWidget(central)
|
||||
|
||||
self._transport = TransportBar(self.player, manager, self._prefs)
|
||||
layout.addWidget(self._transport)
|
||||
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
layout.addWidget(splitter, stretch=1)
|
||||
|
||||
self._sidebar = SidebarPanel(manager, self.player)
|
||||
self._sidebar.setMinimumWidth(140)
|
||||
splitter.addWidget(self._sidebar)
|
||||
|
||||
self._content = QStackedWidget()
|
||||
splitter.addWidget(self._content)
|
||||
splitter.setSizes([220, 1060])
|
||||
splitter.setCollapsible(1, False)
|
||||
|
||||
self._library_view = LibraryView(manager)
|
||||
self._content.addWidget(self._library_view)
|
||||
self._playlist_view = PlaylistView(manager)
|
||||
self._content.addWidget(self._playlist_view)
|
||||
self._content.setCurrentWidget(self._library_view)
|
||||
|
||||
# Bottom status bar: a permanent, centered totals readout (transient
|
||||
# import/scrobble/error messages still use the left showMessage area).
|
||||
self._totals_label = QLabel()
|
||||
self._totals_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.statusBar().addPermanentWidget(self._totals_label, 1)
|
||||
|
||||
# Wiring
|
||||
self._transport.play_clicked.connect(self.play_pause)
|
||||
self._sidebar.library_selected.connect(self._show_library)
|
||||
self._sidebar.playlist_selected.connect(self._show_playlist)
|
||||
self._content.currentChanged.connect(self._update_totals)
|
||||
for view in (self._library_view, self._playlist_view):
|
||||
view.play_requested.connect(
|
||||
lambda ids, start, v=view: self._play_from_view(v, ids, start))
|
||||
view.info_requested.connect(self._show_info)
|
||||
view.show_in_playlist_requested.connect(self._show_track_in_playlist)
|
||||
view.table.tracks_changed.connect(self._update_totals)
|
||||
self._playlist_view.files_dropped.connect(self._import_files_to_playlist)
|
||||
self.player.error_occurred.connect(
|
||||
lambda msg: QMessageBox.warning(self, "Can't play track", msg))
|
||||
self.player.track_missing.connect(self._on_track_missing)
|
||||
self.player.track_changed.connect(self._on_track_changed)
|
||||
self.player.playing_changed.connect(self._update_now_playing_icon)
|
||||
self.player.playing_changed.connect(self._on_playing_changed)
|
||||
manager.track_updated.connect(self._on_track_meta_updated)
|
||||
prefs.changed.connect(self._on_prefs_changed)
|
||||
if lastfm is not None:
|
||||
self.player.track_changed.connect(lastfm.handle_track_started)
|
||||
self.player.track_finished.connect(lastfm.handle_track_finished)
|
||||
lastfm.status_message.connect(
|
||||
lambda msg: self.statusBar().showMessage(msg, 4000))
|
||||
|
||||
self._build_menus()
|
||||
self.apply_ui_metrics()
|
||||
self._update_totals()
|
||||
|
||||
# ---- status bar totals ----
|
||||
|
||||
def _update_totals(self):
|
||||
view = self._content.currentWidget()
|
||||
count, total_ms = view.table.total_stats()
|
||||
if count:
|
||||
noun = "track" if count == 1 else "tracks"
|
||||
self._totals_label.setText(
|
||||
f"{count:,} {noun} · {format_total_time(total_ms)}")
|
||||
else:
|
||||
self._totals_label.setText("")
|
||||
|
||||
# ---- preferences / theming ----
|
||||
|
||||
def _on_prefs_changed(self):
|
||||
theme.apply_theme(QApplication.instance(), self._prefs)
|
||||
self.apply_ui_metrics()
|
||||
self._transport.refresh_theme()
|
||||
|
||||
def apply_ui_metrics(self):
|
||||
metrics = theme.scale_metrics(self._prefs)
|
||||
for view in (self._library_view, self._playlist_view):
|
||||
view.table.verticalHeader().setDefaultSectionSize(metrics["row_height"])
|
||||
self._library_view.set_header_height(metrics["header_strip"])
|
||||
self._playlist_view.set_header_height(metrics["header_strip"])
|
||||
self._sidebar.apply_metrics(metrics["header_strip"])
|
||||
self.statusBar().setMinimumHeight(metrics["status_height"])
|
||||
|
||||
def _show_preferences(self):
|
||||
if self._prefs_dialog is None:
|
||||
self._prefs_dialog = PreferencesDialog(self._prefs, self._lastfm, self)
|
||||
self._prefs_dialog.show()
|
||||
self._prefs_dialog.raise_()
|
||||
self._prefs_dialog.activateWindow()
|
||||
|
||||
def _build_menus(self):
|
||||
bar = self.menuBar()
|
||||
|
||||
file_menu = bar.addMenu("&File")
|
||||
self._add_action(file_menu, "New Playlist", "Ctrl+N",
|
||||
lambda: self._sidebar.tree.create_playlist_interactive())
|
||||
self._add_action(file_menu, "New Playlist Folder", "Ctrl+Shift+N",
|
||||
lambda: self._manager.create_folder("untitled folder"))
|
||||
file_menu.addSeparator()
|
||||
self._add_action(file_menu, "Add Files to Library…", "Ctrl+O",
|
||||
self._open_files_dialog)
|
||||
file_menu.addSeparator()
|
||||
self._add_action(file_menu, "Quit", "Ctrl+Q", self.close)
|
||||
|
||||
edit_menu = bar.addMenu("&Edit")
|
||||
self._undo_action = self._add_action(
|
||||
edit_menu, "Undo", QKeySequence.StandardKey.Undo,
|
||||
self._manager.undo_stack.undo)
|
||||
self._redo_action = self._add_action(
|
||||
edit_menu, "Redo", QKeySequence.StandardKey.Redo,
|
||||
self._manager.undo_stack.redo)
|
||||
# Accept both the platform default (Ctrl+Y) and Ctrl+Shift+Z for redo.
|
||||
self._redo_action.setShortcuts([
|
||||
QKeySequence(QKeySequence.StandardKey.Redo),
|
||||
QKeySequence("Ctrl+Shift+Z"),
|
||||
])
|
||||
edit_menu.addSeparator()
|
||||
self._add_action(edit_menu, "Preferences…", "Ctrl+,", self._show_preferences)
|
||||
self._manager.undo_stack.changed.connect(self._refresh_undo_actions)
|
||||
self._refresh_undo_actions()
|
||||
|
||||
controls = bar.addMenu("&Controls")
|
||||
self._add_action(controls, "Play/Pause", "", self.play_pause)
|
||||
self._add_action(controls, "Next", "", self.player.next)
|
||||
self._add_action(controls, "Previous", "", self.player.previous)
|
||||
|
||||
view_menu = bar.addMenu("&View")
|
||||
self._add_action(view_menu, "Toggle Column Browser", "Ctrl+B",
|
||||
self._library_view.toggle_browser)
|
||||
|
||||
track_menu = bar.addMenu("&Track")
|
||||
self._add_action(track_menu, "Get Info", "Ctrl+I", self._info_for_current_view)
|
||||
self._add_action(track_menu, "Go to Current Song", "Ctrl+L",
|
||||
self._go_to_current_song)
|
||||
|
||||
def _add_action(self, menu, text, shortcut, slot):
|
||||
action = QAction(text, self)
|
||||
if shortcut:
|
||||
action.setShortcut(QKeySequence(shortcut))
|
||||
action.triggered.connect(slot)
|
||||
menu.addAction(action)
|
||||
return action
|
||||
|
||||
def _refresh_undo_actions(self):
|
||||
stack = self._manager.undo_stack
|
||||
can_undo = stack.can_undo()
|
||||
can_redo = stack.can_redo()
|
||||
self._undo_action.setEnabled(can_undo)
|
||||
self._redo_action.setEnabled(can_redo)
|
||||
self._undo_action.setText(
|
||||
f"Undo {stack.undo_label()}" if can_undo else "Undo")
|
||||
self._redo_action.setText(
|
||||
f"Redo {stack.redo_label()}" if can_redo else "Redo")
|
||||
|
||||
# ---- view switching ----
|
||||
|
||||
def _show_library(self):
|
||||
self._content.setCurrentWidget(self._library_view)
|
||||
self._update_now_playing_icon()
|
||||
|
||||
def _show_playlist(self, persistent_id: str):
|
||||
playlist = self._manager.library.playlists.get(persistent_id)
|
||||
if playlist:
|
||||
self._playlist_view.show_playlist(playlist)
|
||||
self._content.setCurrentWidget(self._playlist_view)
|
||||
self._update_now_playing_icon()
|
||||
|
||||
def _go_to_current_song(self):
|
||||
"""Jump to wherever the current song is playing from, select it, and
|
||||
scroll it into view. No-op when nothing is cued."""
|
||||
track = self.player.current_track
|
||||
if track is None:
|
||||
return
|
||||
ctx = self._now_playing_context
|
||||
if ctx == "library":
|
||||
self._show_library()
|
||||
self._sidebar.reflect_library()
|
||||
self._library_view.reveal_track(track.track_id)
|
||||
elif ctx.startswith("playlist:"):
|
||||
pid = ctx.split(":", 1)[1]
|
||||
if pid in self._manager.library.playlists:
|
||||
self._show_playlist(pid)
|
||||
self._sidebar.reflect_playlist(pid)
|
||||
self._playlist_view.reveal_track(track.track_id)
|
||||
|
||||
def _show_track_in_playlist(self, track_id: int, pid: str):
|
||||
"""Jump to a playlist and highlight the first instance of the track,
|
||||
reusing the Go-to-Current-Song navigation pattern."""
|
||||
if pid in self._manager.library.playlists:
|
||||
self._show_playlist(pid)
|
||||
self._sidebar.reflect_playlist(pid)
|
||||
self._playlist_view.reveal_track(track_id)
|
||||
|
||||
# ---- info dialog ----
|
||||
|
||||
def _show_info(self, track_ids: list[int]):
|
||||
if track_ids:
|
||||
InfoDialog(self._manager, track_ids, self).exec()
|
||||
|
||||
def _info_for_current_view(self):
|
||||
view = self._content.currentWidget()
|
||||
ids = view.table.selected_track_ids()
|
||||
self._show_info(ids)
|
||||
|
||||
# ---- file import ----
|
||||
|
||||
def _music_import_dir(self) -> Path:
|
||||
return Path(self._manager.library.music_folder) / "Music"
|
||||
|
||||
def _open_files_dialog(self):
|
||||
paths, _filter = QFileDialog.getOpenFileNames(
|
||||
self, "Add Files to Library", "",
|
||||
"Audio files (*.mp3 *.m4a *.flac *.aac *.wav *.aiff)")
|
||||
if paths:
|
||||
self.import_files([Path(p) for p in paths])
|
||||
|
||||
def import_files(self, paths, playlist_pid: str = ""):
|
||||
self.statusBar().showMessage(f"Importing {len(paths)} item(s)…")
|
||||
imported = file_importer.import_paths(
|
||||
paths, self._music_import_dir(), self._manager, playlist_pid)
|
||||
self.statusBar().showMessage(f"Imported {len(imported)} track(s)", 5000)
|
||||
if imported:
|
||||
self._library_view.reload()
|
||||
|
||||
def _import_files_to_playlist(self, paths, pid):
|
||||
self.import_files([Path(p) for p in paths], pid)
|
||||
|
||||
# ---- relocating a moved/renamed file ----
|
||||
|
||||
def _existing_ancestor(self, path: str) -> str:
|
||||
"""Deepest existing directory at or above `path`'s parent, so the file
|
||||
picker opens somewhere real even if the exact folder is gone."""
|
||||
if path:
|
||||
p = Path(path).parent
|
||||
while not p.exists() and p != p.parent:
|
||||
p = p.parent
|
||||
if p.exists():
|
||||
return str(p)
|
||||
music = self._music_import_dir()
|
||||
return str(music) if music.exists() else str(Path.home())
|
||||
|
||||
def _on_track_missing(self, track):
|
||||
"""A track's file is gone (moved/renamed outside lintunes). Offer to
|
||||
relocate it manually; on success, repoint the track and resume."""
|
||||
box = QMessageBox(self)
|
||||
box.setIcon(QMessageBox.Icon.Warning)
|
||||
box.setWindowTitle("Can't play track")
|
||||
box.setText(f"Can't play “{track.name}”.")
|
||||
box.setInformativeText(
|
||||
f"File not found:\n{track.location or '(no location)'}")
|
||||
locate_btn = box.addButton("Locate File…", QMessageBox.ButtonRole.ActionRole)
|
||||
box.addButton(QMessageBox.StandardButton.Cancel)
|
||||
box.exec()
|
||||
if box.clickedButton() is not locate_btn:
|
||||
return
|
||||
chosen, _filter = QFileDialog.getOpenFileName(
|
||||
self, f"Locate “{track.name}”", self._existing_ancestor(track.location),
|
||||
"Audio files (*.mp3 *.m4a *.flac *.aac *.wav *.aiff *.aif);;All files (*)")
|
||||
if not chosen:
|
||||
return
|
||||
self._manager.set_track_location(track.track_id, chosen)
|
||||
# Defer the retry so it runs after this modal handler unwinds.
|
||||
QTimer.singleShot(0, self.player.retry_current)
|
||||
|
||||
# ---- window-level drops (import anywhere) ----
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
if event.mimeData().hasUrls():
|
||||
event.acceptProposedAction()
|
||||
|
||||
def dropEvent(self, event):
|
||||
paths = [Path(u.toLocalFile()) for u in event.mimeData().urls()
|
||||
if u.isLocalFile()]
|
||||
if paths:
|
||||
self.import_files(paths)
|
||||
event.acceptProposedAction()
|
||||
|
||||
# ---- playback ----
|
||||
|
||||
def play_pause(self):
|
||||
"""Play/pause like iTunes: with nothing cued, start the visible
|
||||
view's tracks (from the selected row, else the top)."""
|
||||
if self.player.current_track is not None:
|
||||
self.player.toggle_play()
|
||||
return
|
||||
view = self._content.currentWidget()
|
||||
ids = view.table.view_order_track_ids()
|
||||
if not ids:
|
||||
return
|
||||
selected = view.table.selectionModel().selectedRows()
|
||||
start = selected[0].row() if selected else 0
|
||||
self._play_from_view(view, ids, start)
|
||||
|
||||
def _play_from_view(self, view, ids, start):
|
||||
"""Start playback, recording which view it came from so the now-playing
|
||||
icon (and Go to Current Song) can find the right context."""
|
||||
self._now_playing_context = view.table.context_id()
|
||||
self.player.play_queue(ids, start)
|
||||
|
||||
def _on_track_changed(self, track):
|
||||
if track is None:
|
||||
self.setWindowTitle("LinTunes")
|
||||
else:
|
||||
self.setWindowTitle(f"{track.name} — {track.artist} · LinTunes")
|
||||
self._update_now_playing_icon()
|
||||
|
||||
def _on_track_meta_updated(self, track_id: int):
|
||||
current = self.player.current_track
|
||||
if current is not None and current.track_id == track_id:
|
||||
self._sidebar.art.refresh()
|
||||
|
||||
def _update_now_playing_icon(self, _playing=None):
|
||||
track = self.player.current_track
|
||||
track_id = track.track_id if track else None
|
||||
playing = self.player.is_playing()
|
||||
# Only the table showing the context playback started from gets the icon,
|
||||
# so the same song appearing in another playlist/library isn't flagged.
|
||||
for view in (self._library_view, self._playlist_view):
|
||||
match = (track_id is not None
|
||||
and view.table.context_id() == self._now_playing_context)
|
||||
view.table.model_.set_now_playing(track_id if match else None,
|
||||
playing)
|
||||
|
||||
# ---- global media keys (spacebar / arrows), per spec detail 8 ----
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
if event.type() == QEvent.Type.KeyPress and self.isActiveWindow():
|
||||
focus = QApplication.focusWidget()
|
||||
if isinstance(focus, (QLineEdit, QPlainTextEdit, QTextEdit,
|
||||
QAbstractSpinBox, QComboBox)):
|
||||
return False
|
||||
key = event.key()
|
||||
if key == Qt.Key.Key_Space:
|
||||
self.play_pause()
|
||||
return True
|
||||
if key == Qt.Key.Key_Right:
|
||||
self.player.next()
|
||||
return True
|
||||
if key == Qt.Key.Key_Left:
|
||||
self.player.previous()
|
||||
return True
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def _on_playing_changed(self, playing: bool):
|
||||
if playing:
|
||||
self._inhibitor.inhibit()
|
||||
else:
|
||||
self._inhibitor.release()
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._inhibitor.release()
|
||||
self._manager.flush()
|
||||
super().closeEvent(event)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Shared playlist-add logic with a duplicate-confirmation prompt.
|
||||
|
||||
All GUI paths that add tracks to a playlist (sidebar drop, drop onto an open
|
||||
playlist's track list, paste) route through here so the duplicate guard is
|
||||
consistent for single- and multi-track adds.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
|
||||
def has_duplicates(track_ids, existing) -> bool:
|
||||
"""Whether any of ``track_ids`` is already in ``existing``."""
|
||||
existing = set(existing)
|
||||
return any(tid in existing for tid in track_ids)
|
||||
|
||||
|
||||
def filter_for_add(track_ids, existing, add_duplicates: bool) -> list[int]:
|
||||
"""The ids to add, preserving order: all of them when ``add_duplicates``,
|
||||
otherwise only the ones not already in ``existing``."""
|
||||
if add_duplicates:
|
||||
return list(track_ids)
|
||||
existing = set(existing)
|
||||
return [tid for tid in track_ids if tid not in existing]
|
||||
|
||||
|
||||
def add_tracks_with_dup_check(manager, parent, pid, track_ids,
|
||||
position=None) -> list[int]:
|
||||
"""Add ``track_ids`` to playlist ``pid``, asking first if any already exist.
|
||||
|
||||
Returns the track ids actually added (empty if the user cancelled or there
|
||||
was nothing to add).
|
||||
"""
|
||||
playlist = manager.library.playlists.get(pid)
|
||||
if not playlist or not track_ids:
|
||||
return []
|
||||
|
||||
existing = playlist.track_ids
|
||||
if has_duplicates(track_ids, existing):
|
||||
to_add = _resolve_duplicates(parent, track_ids, existing)
|
||||
else:
|
||||
to_add = list(track_ids)
|
||||
|
||||
if not to_add:
|
||||
return []
|
||||
manager.add_tracks_to_playlist(pid, to_add, position)
|
||||
return to_add
|
||||
|
||||
|
||||
def _resolve_duplicates(parent, track_ids, existing) -> list[int]:
|
||||
"""Ask whether to re-add duplicates; return the resulting ids to add."""
|
||||
box = QMessageBox(parent)
|
||||
box.setIcon(QMessageBox.Icon.Question)
|
||||
box.setWindowTitle("Duplicate songs")
|
||||
box.setText("One or more songs are already in the playlist. "
|
||||
"Add those ones again?")
|
||||
add_again = box.addButton("Yes, add again",
|
||||
QMessageBox.ButtonRole.AcceptRole)
|
||||
skip = box.addButton("Skip duplicates", QMessageBox.ButtonRole.ActionRole)
|
||||
box.setDefaultButton(skip)
|
||||
box.exec()
|
||||
|
||||
clicked = box.clickedButton()
|
||||
if clicked is add_again:
|
||||
return filter_for_add(track_ids, existing, add_duplicates=True)
|
||||
if clicked is skip:
|
||||
return filter_for_add(track_ids, existing, add_duplicates=False)
|
||||
return [] # dialog dismissed (Escape / close) -> add nothing
|
||||
@@ -0,0 +1,143 @@
|
||||
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QApplication
|
||||
from PyQt6.QtCore import pyqtSignal
|
||||
|
||||
from lintunes.gui.track_table import TrackTableView, parse_tracks_mime
|
||||
from lintunes.gui.playlist_ops import add_tracks_with_dup_check
|
||||
|
||||
|
||||
class PlaylistView(QWidget):
|
||||
play_requested = pyqtSignal(list, int)
|
||||
info_requested = pyqtSignal(list)
|
||||
files_dropped = pyqtSignal(list, str) # paths, playlist pid
|
||||
show_in_playlist_requested = pyqtSignal(int, str) # track_id, playlist pid
|
||||
|
||||
def __init__(self, manager, parent=None):
|
||||
super().__init__(parent)
|
||||
self._manager = manager
|
||||
self._playlist = None
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
self._name_label = QLabel()
|
||||
self._name_label.setStyleSheet("font-weight: bold; padding: 0 8px;")
|
||||
self._name_label.setFixedHeight(34)
|
||||
layout.addWidget(self._name_label)
|
||||
|
||||
self.table = TrackTableView(playlist_mode=True)
|
||||
layout.addWidget(self.table)
|
||||
|
||||
self.table.playlists_for_track = manager.playlists_containing
|
||||
self.table.play_requested.connect(self.play_requested)
|
||||
self.table.info_requested.connect(self.info_requested)
|
||||
self.table.show_in_playlist_requested.connect(
|
||||
self.show_in_playlist_requested)
|
||||
self.table.sort_changed.connect(self._on_sort_changed)
|
||||
self.table.columns_changed.connect(self._on_columns_changed)
|
||||
self.table.column_width_changed.connect(self._on_width_changed)
|
||||
self.table.reorder_requested.connect(self._on_reorder)
|
||||
self.table.tracks_dropped.connect(self._on_tracks_dropped)
|
||||
self.table.files_dropped.connect(self._on_files_dropped)
|
||||
self.table.remove_requested.connect(self._on_remove)
|
||||
self.table.paste_requested.connect(self._on_paste)
|
||||
|
||||
manager.playlist_content_changed.connect(self._on_content_changed)
|
||||
manager.track_updated.connect(self.table.model_.refresh_track)
|
||||
manager.playlists_changed.connect(self._on_playlists_changed)
|
||||
|
||||
def set_header_height(self, height: int):
|
||||
# Kept in sync with the sidebar's Library button so the tree's top
|
||||
# aligns with the tracklist's top
|
||||
self._name_label.setFixedHeight(height)
|
||||
|
||||
@property
|
||||
def playlist_id(self) -> str:
|
||||
return self._playlist.persistent_id if self._playlist else ""
|
||||
|
||||
def reveal_track(self, track_id: int):
|
||||
self.table.reveal_track(track_id)
|
||||
|
||||
def show_playlist(self, playlist):
|
||||
self._playlist = playlist
|
||||
self.table.set_source_playlist(playlist.persistent_id)
|
||||
self.table.apply_settings(playlist.settings)
|
||||
self._reload_tracks()
|
||||
|
||||
def _reload_tracks(self):
|
||||
if self._playlist is None:
|
||||
return
|
||||
tracks = [self._manager.library.tracks[tid]
|
||||
for tid in self._playlist.track_ids
|
||||
if tid in self._manager.library.tracks]
|
||||
self.table.set_tracks(tracks)
|
||||
# Count/time now live in the bottom status bar, not next to the name.
|
||||
self._name_label.setText(self._playlist.name)
|
||||
|
||||
def _on_content_changed(self, pid):
|
||||
if self._playlist is not None and pid == self._playlist.persistent_id:
|
||||
self._reload_tracks()
|
||||
|
||||
def _on_playlists_changed(self):
|
||||
# Playlist may have been renamed or deleted
|
||||
if self._playlist is None:
|
||||
return
|
||||
if self._playlist.persistent_id not in self._manager.library.playlists:
|
||||
self._playlist = None
|
||||
self.table.set_tracks([])
|
||||
self._name_label.setText("")
|
||||
else:
|
||||
self._reload_tracks()
|
||||
|
||||
# ---- settings persistence ----
|
||||
|
||||
def _on_sort_changed(self, field, ascending):
|
||||
if self._playlist is None:
|
||||
return
|
||||
self._playlist.settings.sort_column = field
|
||||
self._playlist.settings.sort_ascending = ascending
|
||||
self._manager.mark_playlist_settings_dirty(self._playlist.persistent_id)
|
||||
|
||||
def _on_columns_changed(self, columns):
|
||||
if self._playlist is None:
|
||||
return
|
||||
self._playlist.settings.visible_columns = columns
|
||||
self.table.apply_settings(self._playlist.settings)
|
||||
self._manager.mark_playlist_settings_dirty(self._playlist.persistent_id)
|
||||
|
||||
def _on_width_changed(self, field, width):
|
||||
if self._playlist is None:
|
||||
return
|
||||
self._playlist.settings.column_widths[field] = width
|
||||
self._manager.mark_playlist_settings_dirty(self._playlist.persistent_id)
|
||||
|
||||
# ---- content edits ----
|
||||
|
||||
def _on_reorder(self, rows, dest):
|
||||
if self._playlist is not None:
|
||||
self._manager.move_tracks_in_playlist(
|
||||
self._playlist.persistent_id, rows, dest)
|
||||
|
||||
def _on_tracks_dropped(self, track_ids, drop_row):
|
||||
if self._playlist is not None:
|
||||
add_tracks_with_dup_check(
|
||||
self._manager, self, self._playlist.persistent_id,
|
||||
track_ids, drop_row)
|
||||
|
||||
def _on_files_dropped(self, paths, drop_row):
|
||||
if self._playlist is not None:
|
||||
self.files_dropped.emit(paths, self._playlist.persistent_id)
|
||||
|
||||
def _on_remove(self, rows):
|
||||
if self._playlist is not None:
|
||||
self._manager.remove_tracks_from_playlist(
|
||||
self._playlist.persistent_id, rows)
|
||||
|
||||
def _on_paste(self):
|
||||
if self._playlist is None:
|
||||
return
|
||||
payload = parse_tracks_mime(QApplication.clipboard().mimeData())
|
||||
if payload:
|
||||
add_tracks_with_dup_check(
|
||||
self._manager, self, self._playlist.persistent_id,
|
||||
payload.get("track_ids", []))
|
||||
@@ -0,0 +1,154 @@
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, QSlider,
|
||||
QRadioButton, QButtonGroup, QLineEdit, QPushButton, QCheckBox,
|
||||
QFormLayout,
|
||||
)
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtGui import QPixmap, QColor, QIcon
|
||||
|
||||
from lintunes.theme import HIGHLIGHT_COLORS
|
||||
|
||||
|
||||
_SCALES = ["small", "medium", "large"]
|
||||
|
||||
|
||||
def _swatch(color_hex: str) -> QIcon:
|
||||
pixmap = QPixmap(18, 18)
|
||||
pixmap.fill(QColor(color_hex))
|
||||
return QIcon(pixmap)
|
||||
|
||||
|
||||
class PreferencesDialog(QDialog):
|
||||
def __init__(self, prefs, lastfm, parent=None):
|
||||
super().__init__(parent)
|
||||
self._prefs = prefs
|
||||
self._lastfm = lastfm
|
||||
self.setWindowTitle("Preferences")
|
||||
self.setMinimumWidth(420)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self._build_general_group())
|
||||
layout.addWidget(self._build_lastfm_group())
|
||||
layout.addStretch()
|
||||
|
||||
lastfm.login_finished.connect(self._on_login_finished)
|
||||
self._refresh_lastfm_state()
|
||||
|
||||
# ---- General ----
|
||||
|
||||
def _build_general_group(self) -> QGroupBox:
|
||||
group = QGroupBox("General")
|
||||
form = QFormLayout(group)
|
||||
|
||||
# UI size: 3-position slider
|
||||
size_row = QHBoxLayout()
|
||||
self._size_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self._size_slider.setRange(0, 2)
|
||||
self._size_slider.setPageStep(1)
|
||||
self._size_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
|
||||
self._size_slider.setTickInterval(1)
|
||||
self._size_slider.setFixedWidth(160)
|
||||
current_scale = self._prefs.get("ui_scale", "medium")
|
||||
self._size_slider.setValue(
|
||||
_SCALES.index(current_scale) if current_scale in _SCALES else 1)
|
||||
self._size_label = QLabel(current_scale.capitalize())
|
||||
self._size_slider.valueChanged.connect(self._on_size_changed)
|
||||
size_row.addWidget(QLabel("Small"))
|
||||
size_row.addWidget(self._size_slider)
|
||||
size_row.addWidget(QLabel("Large"))
|
||||
size_row.addSpacing(8)
|
||||
size_row.addWidget(self._size_label)
|
||||
size_row.addStretch()
|
||||
form.addRow("UI size:", size_row)
|
||||
|
||||
# Highlight color swatches
|
||||
color_row = QHBoxLayout()
|
||||
self._color_group = QButtonGroup(self)
|
||||
for name, hex_value in HIGHLIGHT_COLORS.items():
|
||||
radio = QRadioButton(name)
|
||||
radio.setIcon(_swatch(hex_value))
|
||||
radio.setChecked(self._prefs.get("highlight") == name)
|
||||
radio.toggled.connect(
|
||||
lambda checked, n=name: checked and self._prefs.set("highlight", n))
|
||||
self._color_group.addButton(radio)
|
||||
color_row.addWidget(radio)
|
||||
color_row.addStretch()
|
||||
form.addRow("Highlight:", color_row)
|
||||
return group
|
||||
|
||||
def _on_size_changed(self, value: int):
|
||||
scale = _SCALES[value]
|
||||
self._size_label.setText(scale.capitalize())
|
||||
self._prefs.set("ui_scale", scale)
|
||||
|
||||
# ---- Last.fm ----
|
||||
|
||||
def _build_lastfm_group(self) -> QGroupBox:
|
||||
group = QGroupBox("Last.fm")
|
||||
form = QFormLayout(group)
|
||||
|
||||
hint = QLabel(
|
||||
'Needs a free API account: '
|
||||
'<a href="https://www.last.fm/api/account/create">'
|
||||
'last.fm/api/account/create</a>')
|
||||
hint.setOpenExternalLinks(True)
|
||||
form.addRow(hint)
|
||||
|
||||
lf = self._prefs.lastfm
|
||||
self._api_key = QLineEdit(lf.get("api_key", ""))
|
||||
self._api_secret = QLineEdit(lf.get("api_secret", ""))
|
||||
self._api_secret.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self._username = QLineEdit(lf.get("username", ""))
|
||||
self._password = QLineEdit()
|
||||
self._password.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
form.addRow("API key:", self._api_key)
|
||||
form.addRow("Shared secret:", self._api_secret)
|
||||
form.addRow("Username:", self._username)
|
||||
form.addRow("Password:", self._password)
|
||||
|
||||
button_row = QHBoxLayout()
|
||||
self._login_button = QPushButton("Log in")
|
||||
self._login_button.clicked.connect(self._on_login_clicked)
|
||||
self._status_label = QLabel("")
|
||||
button_row.addWidget(self._login_button)
|
||||
button_row.addWidget(self._status_label)
|
||||
button_row.addStretch()
|
||||
form.addRow("", button_row)
|
||||
|
||||
self._scrobble_check = QCheckBox("Enable scrobbling")
|
||||
self._scrobble_check.setChecked(lf.get("scrobble_enabled", False))
|
||||
self._scrobble_check.toggled.connect(
|
||||
lambda on: self._prefs.update_lastfm(scrobble_enabled=on))
|
||||
form.addRow("", self._scrobble_check)
|
||||
return group
|
||||
|
||||
def _on_login_clicked(self):
|
||||
if self._lastfm.is_logged_in():
|
||||
self._lastfm.logout()
|
||||
self._refresh_lastfm_state()
|
||||
return
|
||||
api_key = self._api_key.text().strip()
|
||||
secret = self._api_secret.text().strip()
|
||||
username = self._username.text().strip()
|
||||
password = self._password.text()
|
||||
if not all((api_key, secret, username, password)):
|
||||
self._status_label.setText("Fill in all four fields first")
|
||||
return
|
||||
self._login_button.setEnabled(False)
|
||||
self._status_label.setText("Logging in…")
|
||||
self._lastfm.login(api_key, secret, username, password)
|
||||
|
||||
def _on_login_finished(self, success: bool, message: str):
|
||||
self._status_label.setText(message)
|
||||
self._login_button.setEnabled(True)
|
||||
if success:
|
||||
self._password.clear()
|
||||
self._refresh_lastfm_state()
|
||||
|
||||
def _refresh_lastfm_state(self):
|
||||
logged_in = self._lastfm.is_logged_in()
|
||||
self._login_button.setText("Log out" if logged_in else "Log in")
|
||||
self._scrobble_check.setEnabled(logged_in)
|
||||
if logged_in and not self._status_label.text():
|
||||
self._status_label.setText(
|
||||
f"Logged in as {self._prefs.lastfm.get('username', '')}")
|
||||
@@ -0,0 +1,409 @@
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu,
|
||||
QInputDialog, QMessageBox, QAbstractItemView,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
|
||||
from PyQt6.QtGui import QBrush, QPainter, QPixmap
|
||||
|
||||
from lintunes.models import PlaylistType
|
||||
from lintunes import tagging
|
||||
from lintunes.gui.track_table import parse_tracks_mime, TRACKS_MIME
|
||||
from lintunes.gui.playlist_ops import add_tracks_with_dup_check
|
||||
from lintunes.gui.art_window import ArtWindow
|
||||
from lintunes.gui import drag_ghost
|
||||
|
||||
|
||||
PID_ROLE = Qt.ItemDataRole.UserRole
|
||||
KIND_ROLE = Qt.ItemDataRole.UserRole + 1 # "folder" | "playlist"
|
||||
|
||||
|
||||
class SidebarPanel(QWidget):
|
||||
"""Left panel: Library button on top, playlist tree, album art nestled
|
||||
flush at the bottom."""
|
||||
|
||||
library_selected = pyqtSignal()
|
||||
playlist_selected = pyqtSignal(str)
|
||||
|
||||
def __init__(self, manager, player, parent=None):
|
||||
super().__init__(parent)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
self.library_button = QPushButton("Library")
|
||||
self.library_button.setCheckable(True)
|
||||
self.library_button.setChecked(True)
|
||||
self.library_button.setStyleSheet(
|
||||
"QPushButton { text-align: left; padding-left: 10px;"
|
||||
" border: 1px solid palette(mid); border-radius: 4px;"
|
||||
" margin: 2px 4px 2px 8px; background: palette(alternate-base); }"
|
||||
"QPushButton:checked { background: palette(highlight);"
|
||||
" color: palette(highlighted-text); }")
|
||||
layout.addWidget(self.library_button)
|
||||
|
||||
self.tree = PlaylistTree(manager)
|
||||
layout.addWidget(self.tree, stretch=1)
|
||||
|
||||
self.art = SidebarArt(player)
|
||||
layout.addWidget(self.art)
|
||||
|
||||
self.library_button.clicked.connect(self._on_library_clicked)
|
||||
self.tree.playlist_selected.connect(self._on_playlist_selected)
|
||||
|
||||
def _on_library_clicked(self):
|
||||
self.library_button.setChecked(True)
|
||||
self.tree.clearSelection()
|
||||
self.tree.setCurrentItem(None)
|
||||
self.library_selected.emit()
|
||||
|
||||
def _on_playlist_selected(self, pid: str):
|
||||
self.library_button.setChecked(False)
|
||||
self.playlist_selected.emit(pid)
|
||||
|
||||
def current_playlist_id(self) -> str:
|
||||
return self.tree.current_playlist_id()
|
||||
|
||||
def reflect_library(self):
|
||||
"""Mirror 'Library is selected' in the sidebar without re-navigating."""
|
||||
self.library_button.setChecked(True)
|
||||
self.tree.blockSignals(True)
|
||||
self.tree.clearSelection()
|
||||
self.tree.setCurrentItem(None)
|
||||
self.tree.blockSignals(False)
|
||||
|
||||
def reflect_playlist(self, pid: str):
|
||||
"""Mirror 'this playlist is selected' without re-navigating."""
|
||||
self.library_button.setChecked(False)
|
||||
self.tree.select_item(pid)
|
||||
|
||||
def apply_metrics(self, header_strip: int):
|
||||
self.library_button.setFixedHeight(header_strip)
|
||||
|
||||
|
||||
class SidebarArt(QWidget):
|
||||
"""Square album art pinned to the sidebar bottom; gray square when the
|
||||
current track has no embedded art. Double-click opens the big art window."""
|
||||
|
||||
def __init__(self, player, parent=None):
|
||||
super().__init__(parent)
|
||||
self._player = player
|
||||
self._pixmap: QPixmap | None = None
|
||||
self._track = None
|
||||
self._art_window = ArtWindow()
|
||||
self.setToolTip("Double-click to view full size")
|
||||
player.track_changed.connect(self.set_track)
|
||||
|
||||
def set_track(self, track):
|
||||
self._track = track
|
||||
self._pixmap = None
|
||||
if track is not None and track.location:
|
||||
data = tagging.read_embedded_artwork(track.location)
|
||||
if data:
|
||||
pixmap = QPixmap()
|
||||
if pixmap.loadFromData(data):
|
||||
self._pixmap = pixmap
|
||||
if self._art_window.isVisible():
|
||||
self._art_window.set_artwork(self._pixmap, self._title())
|
||||
self.update()
|
||||
|
||||
def refresh(self):
|
||||
"""Re-read art for the current track (after tag edits)."""
|
||||
self.set_track(self._track)
|
||||
|
||||
def _title(self) -> str:
|
||||
if self._track is None:
|
||||
return "Album Art"
|
||||
return (" — ".join(p for p in (self._track.artist, self._track.album) if p)
|
||||
or self._track.name)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
# Keep it square: height follows the sidebar's width
|
||||
if self.height() != self.width():
|
||||
self.setFixedHeight(self.width())
|
||||
super().resizeEvent(event)
|
||||
|
||||
def mouseDoubleClickEvent(self, event):
|
||||
if self._pixmap is not None:
|
||||
self._art_window.set_artwork(self._pixmap, self._title())
|
||||
self._art_window.show()
|
||||
self._art_window.raise_()
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.fillRect(self.rect(), self.palette().mid().color())
|
||||
if self._pixmap is None or self._pixmap.isNull():
|
||||
return
|
||||
scaled = self._pixmap.scaled(
|
||||
self.size(), Qt.AspectRatioMode.KeepAspectRatioByExpanding,
|
||||
Qt.TransformationMode.SmoothTransformation)
|
||||
x = (self.width() - scaled.width()) // 2
|
||||
y = (self.height() - scaled.height()) // 2
|
||||
painter.drawPixmap(x, y, scaled)
|
||||
|
||||
|
||||
class PlaylistTree(QTreeWidget):
|
||||
"""The user's playlist folder tree. Mutations go through LibraryManager;
|
||||
the tree rebuilds on its playlists_changed signal."""
|
||||
|
||||
playlist_selected = pyqtSignal(str) # persistent_id
|
||||
|
||||
def __init__(self, manager, parent=None):
|
||||
super().__init__(parent)
|
||||
self._manager = manager
|
||||
self._drop_target: QTreeWidgetItem | None = None
|
||||
|
||||
self.setHeaderHidden(True)
|
||||
self.setIndentation(14)
|
||||
self.setAcceptDrops(True)
|
||||
self.setDragEnabled(True)
|
||||
self.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop)
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(self._show_context_menu)
|
||||
self.currentItemChanged.connect(self._on_selection_changed)
|
||||
self.itemChanged.connect(self._on_item_renamed)
|
||||
|
||||
manager.playlists_changed.connect(self.rebuild)
|
||||
self.rebuild()
|
||||
|
||||
# ---- tree construction ----
|
||||
|
||||
def rebuild(self):
|
||||
selected_pid = self.current_playlist_id()
|
||||
# Preserve which folders the user has open across rebuilds. On the very
|
||||
# first build this set is empty, so the app opens fully collapsed.
|
||||
expanded = self._expanded_pids()
|
||||
self.blockSignals(True)
|
||||
self.clear()
|
||||
|
||||
playlists = self._manager.library.playlists
|
||||
children: dict[str, list] = {}
|
||||
for playlist in playlists.values():
|
||||
parent_pid = playlist.parent_persistent_id
|
||||
if parent_pid not in playlists:
|
||||
parent_pid = ""
|
||||
children.setdefault(parent_pid, []).append(playlist)
|
||||
|
||||
def add_level(parent_item, parent_pid):
|
||||
for playlist in sorted(children.get(parent_pid, []),
|
||||
key=lambda p: p.name.lower()):
|
||||
item = QTreeWidgetItem([playlist.name])
|
||||
item.setData(0, PID_ROLE, playlist.persistent_id)
|
||||
is_folder = playlist.playlist_type == PlaylistType.FOLDER
|
||||
item.setData(0, KIND_ROLE, "folder" if is_folder else "playlist")
|
||||
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable)
|
||||
if parent_item is None:
|
||||
self.addTopLevelItem(item)
|
||||
else:
|
||||
parent_item.addChild(item)
|
||||
if is_folder:
|
||||
add_level(item, playlist.persistent_id)
|
||||
item.setExpanded(playlist.persistent_id in expanded)
|
||||
|
||||
add_level(None, "")
|
||||
self.blockSignals(False)
|
||||
|
||||
if selected_pid:
|
||||
item = self._find_item(selected_pid)
|
||||
if item:
|
||||
self.setCurrentItem(item)
|
||||
|
||||
def _expanded_pids(self) -> set[str]:
|
||||
"""Persistent ids of folders currently expanded in the tree."""
|
||||
result: set[str] = set()
|
||||
stack = [self.topLevelItem(i) for i in range(self.topLevelItemCount())]
|
||||
while stack:
|
||||
item = stack.pop()
|
||||
if item.data(0, KIND_ROLE) == "folder" and item.isExpanded():
|
||||
result.add(item.data(0, PID_ROLE))
|
||||
stack.extend(item.child(i) for i in range(item.childCount()))
|
||||
return result
|
||||
|
||||
def _find_item(self, pid: str):
|
||||
stack = [self.topLevelItem(i) for i in range(self.topLevelItemCount())]
|
||||
while stack:
|
||||
item = stack.pop()
|
||||
if item.data(0, PID_ROLE) == pid:
|
||||
return item
|
||||
stack.extend(item.child(i) for i in range(item.childCount()))
|
||||
return None
|
||||
|
||||
def current_playlist_id(self) -> str:
|
||||
item = self.currentItem()
|
||||
if item is None:
|
||||
return ""
|
||||
return item.data(0, PID_ROLE) or ""
|
||||
|
||||
def select_item(self, pid: str):
|
||||
"""Select a playlist row without emitting the selection signal."""
|
||||
item = self._find_item(pid)
|
||||
if item is not None:
|
||||
self.blockSignals(True)
|
||||
self.setCurrentItem(item)
|
||||
self.blockSignals(False)
|
||||
|
||||
def create_playlist_interactive(self, parent_pid: str = ""):
|
||||
"""Create a playlist, select it, and open inline rename so the user can
|
||||
name it just by typing (Enter commits, Escape keeps "untitled playlist").
|
||||
The manager's playlists_changed signal rebuilds the tree synchronously,
|
||||
so the new item already exists by the time create_playlist() returns."""
|
||||
playlist = self._manager.create_playlist("untitled playlist", parent_pid)
|
||||
item = self._find_item(playlist.persistent_id)
|
||||
if item is not None:
|
||||
self.setCurrentItem(item) # selects + navigates to the new playlist
|
||||
self.editItem(item, 0) # live inline rename; editor selects all
|
||||
|
||||
# ---- selection / rename ----
|
||||
|
||||
def _on_selection_changed(self, current, _previous):
|
||||
if current is None:
|
||||
return
|
||||
if current.data(0, KIND_ROLE) == "playlist":
|
||||
self.playlist_selected.emit(current.data(0, PID_ROLE))
|
||||
|
||||
def _on_item_renamed(self, item, _column):
|
||||
pid = item.data(0, PID_ROLE)
|
||||
if pid:
|
||||
self._manager.rename_playlist(pid, item.text(0).strip())
|
||||
|
||||
# ---- context menu ----
|
||||
|
||||
def _show_context_menu(self, pos):
|
||||
item = self.itemAt(pos)
|
||||
kind = item.data(0, KIND_ROLE) if item else ""
|
||||
pid = item.data(0, PID_ROLE) if item else ""
|
||||
# New items land inside the folder under the cursor (or at top level)
|
||||
parent_pid = ""
|
||||
if kind == "folder":
|
||||
parent_pid = pid
|
||||
elif kind == "playlist":
|
||||
playlist = self._manager.library.playlists.get(pid)
|
||||
if playlist:
|
||||
parent_pid = playlist.parent_persistent_id
|
||||
|
||||
menu = QMenu(self)
|
||||
new_playlist = menu.addAction("New Playlist")
|
||||
new_folder = menu.addAction("New Playlist Folder")
|
||||
rename = delete = None
|
||||
if kind in ("playlist", "folder"):
|
||||
menu.addSeparator()
|
||||
rename = menu.addAction("Rename")
|
||||
delete = menu.addAction("Delete")
|
||||
|
||||
chosen = menu.exec(self.viewport().mapToGlobal(pos))
|
||||
if chosen is None:
|
||||
return
|
||||
if chosen is new_playlist:
|
||||
self.create_playlist_interactive(parent_pid)
|
||||
elif chosen is new_folder:
|
||||
name, ok = QInputDialog.getText(self, "New Playlist Folder", "Name:",
|
||||
text="untitled folder")
|
||||
if ok and name.strip():
|
||||
self._manager.create_folder(name.strip(), parent_pid)
|
||||
elif rename is not None and chosen is rename:
|
||||
self.editItem(item, 0)
|
||||
elif delete is not None and chosen is delete:
|
||||
label = "folder (and everything in it)" if kind == "folder" else "playlist"
|
||||
answer = QMessageBox.question(
|
||||
self, "Delete", f'Delete the {label} "{item.text(0)}"?')
|
||||
if answer == QMessageBox.StandardButton.Yes:
|
||||
self._manager.delete_playlist(pid)
|
||||
|
||||
# ---- drag and drop ----
|
||||
|
||||
def mimeTypes(self):
|
||||
return [TRACKS_MIME]
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
if event.mimeData().hasFormat(TRACKS_MIME) or event.source() is self:
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
event.ignore()
|
||||
|
||||
def dragMoveEvent(self, event):
|
||||
drag_ghost.move(self.viewport().mapToGlobal(event.position().toPoint()))
|
||||
if event.source() is self:
|
||||
# Moving a playlist into/out of folders
|
||||
self._clear_drop_target()
|
||||
target = self.itemAt(event.position().toPoint())
|
||||
kind = target.data(0, KIND_ROLE) if target else ""
|
||||
if kind in ("folder", "") or target is None:
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
event.ignore()
|
||||
return
|
||||
if not event.mimeData().hasFormat(TRACKS_MIME):
|
||||
self._clear_drop_target()
|
||||
event.ignore()
|
||||
return
|
||||
target = self.itemAt(event.position().toPoint())
|
||||
if target and target.data(0, KIND_ROLE) == "playlist":
|
||||
self._set_drop_target(target)
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
self._clear_drop_target()
|
||||
event.ignore()
|
||||
|
||||
def dragLeaveEvent(self, event):
|
||||
self._clear_drop_target()
|
||||
super().dragLeaveEvent(event)
|
||||
|
||||
def _set_drop_target(self, item):
|
||||
"""Highlight the playlist row a drop would land on (cleared on the way
|
||||
out). Foreground is set too so the name stays legible on the highlight."""
|
||||
if item is self._drop_target:
|
||||
return
|
||||
self._clear_drop_target()
|
||||
self._drop_target = item
|
||||
self._drop_target_bg = item.background(0)
|
||||
self._drop_target_fg = item.foreground(0)
|
||||
item.setBackground(0, QBrush(self.palette().highlight().color()))
|
||||
item.setForeground(0, QBrush(self.palette().highlightedText().color()))
|
||||
|
||||
def _clear_drop_target(self):
|
||||
if self._drop_target is not None:
|
||||
self._drop_target.setBackground(0, self._drop_target_bg)
|
||||
self._drop_target.setForeground(0, self._drop_target_fg)
|
||||
self._drop_target = None
|
||||
|
||||
def dropEvent(self, event):
|
||||
self._clear_drop_target()
|
||||
target = self.itemAt(event.position().toPoint())
|
||||
if event.source() is self:
|
||||
item = self.currentItem()
|
||||
pid = item.data(0, PID_ROLE) if item else ""
|
||||
if not pid:
|
||||
return
|
||||
new_parent = ""
|
||||
if target is not None and target.data(0, KIND_ROLE) == "folder":
|
||||
new_parent = target.data(0, PID_ROLE)
|
||||
self._manager.move_playlist(pid, new_parent)
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
payload = parse_tracks_mime(event.mimeData())
|
||||
if payload and target and target.data(0, KIND_ROLE) == "playlist":
|
||||
added = add_tracks_with_dup_check(
|
||||
self._manager, self, target.data(0, PID_ROLE),
|
||||
payload.get("track_ids", []))
|
||||
if added:
|
||||
self._flash_item(target)
|
||||
event.acceptProposedAction()
|
||||
|
||||
def _flash_item(self, item):
|
||||
"""Briefly pulse a playlist row's highlight to confirm a drop landed.
|
||||
|
||||
Adding tracks emits playlist_content_changed (not playlists_changed),
|
||||
so the tree isn't rebuilt and the item reference stays valid.
|
||||
"""
|
||||
highlight = QBrush(self.palette().highlight().color())
|
||||
original = item.background(0)
|
||||
|
||||
def on():
|
||||
item.setBackground(0, highlight)
|
||||
|
||||
def off():
|
||||
item.setBackground(0, original)
|
||||
|
||||
on()
|
||||
QTimer.singleShot(150, off)
|
||||
QTimer.singleShot(300, on)
|
||||
QTimer.singleShot(450, off)
|
||||
@@ -0,0 +1,747 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QTableView, QAbstractItemView, QMenu, QApplication,
|
||||
)
|
||||
from PyQt6.QtCore import (
|
||||
Qt, QAbstractTableModel, QModelIndex, QSortFilterProxyModel, pyqtSignal,
|
||||
QMimeData, QUrl, QPoint, QPointF, QRectF, QMetaType,
|
||||
)
|
||||
from PyQt6.QtGui import (
|
||||
QDrag, QKeySequence, QShortcut, QPixmap, QPainter, QPen, QPolygonF, QColor,
|
||||
QFontMetrics, QDesktopServices, QCursor,
|
||||
)
|
||||
from PyQt6.QtDBus import (
|
||||
QDBusConnection, QDBusInterface, QDBusMessage, QDBusArgument,
|
||||
)
|
||||
|
||||
from lintunes.models import Track
|
||||
from lintunes.gui import drag_ghost
|
||||
|
||||
|
||||
TRACKS_MIME = "application/x-lintunes-tracks"
|
||||
SORT_ROLE = Qt.ItemDataRole.UserRole
|
||||
|
||||
# Manual play-order pseudo-column (playlist views only, always first)
|
||||
INDEX_FIELD = "#"
|
||||
|
||||
# Column definitions: (field_name, display_name, default_width)
|
||||
ALL_COLUMNS = [
|
||||
("name", "Name", 250),
|
||||
("artist", "Artist", 180),
|
||||
("album", "Album", 180),
|
||||
("album_artist", "Album Artist", 180),
|
||||
("genre", "Genre", 120),
|
||||
("total_time", "Time", 60),
|
||||
("year", "Year", 50),
|
||||
("track_number", "Track #", 60),
|
||||
("disc_number", "Disc", 40),
|
||||
("play_count", "Plays", 50),
|
||||
("skip_count", "Skips", 50),
|
||||
("rating", "Rating", 80),
|
||||
("date_added", "Date Added", 140),
|
||||
("date_modified", "Date Modified", 140),
|
||||
("play_date_utc", "Last Played", 140),
|
||||
("skip_date", "Last Skipped", 140),
|
||||
("bit_rate", "Bit Rate", 60),
|
||||
("sample_rate", "Sample Rate", 80),
|
||||
("composer", "Composer", 150),
|
||||
("grouping", "Grouping", 120),
|
||||
("comments", "Comments", 150),
|
||||
("bpm", "BPM", 50),
|
||||
("kind", "Kind", 120),
|
||||
("size", "Size", 70),
|
||||
]
|
||||
|
||||
COLUMN_MAP = {col[0]: col for col in ALL_COLUMNS}
|
||||
|
||||
|
||||
def format_time(ms: int) -> str:
|
||||
if ms <= 0:
|
||||
return ""
|
||||
total_secs = round(ms / 1000)
|
||||
mins, secs = divmod(total_secs, 60)
|
||||
if mins >= 60:
|
||||
hours, mins = divmod(mins, 60)
|
||||
return f"{hours}:{mins:02d}:{secs:02d}"
|
||||
return f"{mins}:{secs:02d}"
|
||||
|
||||
|
||||
def format_total_time(ms: int) -> str:
|
||||
"""Trimmed DD:HH:MM:SS: drop empty leading units, keep at least M:SS.
|
||||
|
||||
e.g. 14:21, 9:47:33, 24:18:42:07."""
|
||||
total_secs = round(max(0, ms) / 1000)
|
||||
days, rem = divmod(total_secs, 86400)
|
||||
hours, rem = divmod(rem, 3600)
|
||||
mins, secs = divmod(rem, 60)
|
||||
parts = [days, hours, mins, secs]
|
||||
while len(parts) > 2 and parts[0] == 0:
|
||||
parts.pop(0)
|
||||
return ":".join(str(p) if i == 0 else f"{p:02d}"
|
||||
for i, p in enumerate(parts))
|
||||
|
||||
|
||||
def _format_rating(rating: int) -> str:
|
||||
if rating <= 0:
|
||||
return ""
|
||||
return "★" * (rating // 20)
|
||||
|
||||
|
||||
def _format_size(size: int) -> str:
|
||||
if size <= 0:
|
||||
return ""
|
||||
mb = size / (1024 * 1024)
|
||||
return f"{mb:.1f} MB"
|
||||
|
||||
|
||||
def _format_date(iso_str: str) -> str:
|
||||
return iso_str[:10] if iso_str else ""
|
||||
|
||||
|
||||
# Now-playing speaker pixmaps, cached per (playing, color)
|
||||
_speaker_cache: dict[tuple[bool, str], QPixmap] = {}
|
||||
|
||||
|
||||
def speaker_pixmap(playing: bool, color: QColor) -> QPixmap:
|
||||
"""16px speaker silhouette; with sound waves when playing."""
|
||||
key = (playing, color.name())
|
||||
cached = _speaker_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
from PyQt6.QtCore import QPointF, QRectF
|
||||
# 19px wide (not 16) so the outer wave arc, which reaches ~x=17.7 with
|
||||
# its pen, isn't clipped on the right. Height stays 16.
|
||||
pixmap = QPixmap(19, 16)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(color)
|
||||
painter.drawPolygon(QPolygonF([
|
||||
QPointF(1, 5.5), QPointF(4.5, 5.5), QPointF(8.5, 1.5),
|
||||
QPointF(8.5, 14.5), QPointF(4.5, 10.5), QPointF(1, 10.5),
|
||||
]))
|
||||
if playing:
|
||||
pen = QPen(color, 1.4)
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawArc(QRectF(9.5, 5.5, 4, 5), -60 * 16, 120 * 16)
|
||||
painter.drawArc(QRectF(10.5, 3.0, 6.5, 10), -60 * 16, 120 * 16)
|
||||
painter.end()
|
||||
_speaker_cache[key] = pixmap
|
||||
return pixmap
|
||||
|
||||
|
||||
def make_tracks_mime(track_ids: list[int], source_playlist: str = "",
|
||||
rows: list[int] | None = None) -> QMimeData:
|
||||
mime = QMimeData()
|
||||
payload = {"track_ids": track_ids, "source_playlist": source_playlist,
|
||||
"rows": rows or []}
|
||||
mime.setData(TRACKS_MIME, json.dumps(payload).encode())
|
||||
return mime
|
||||
|
||||
|
||||
def parse_tracks_mime(mime: QMimeData) -> dict | None:
|
||||
if not mime.hasFormat(TRACKS_MIME):
|
||||
return None
|
||||
try:
|
||||
return json.loads(bytes(mime.data(TRACKS_MIME)).decode())
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def reveal_paths(paths: list[str]) -> None:
|
||||
"""Select the given files in the desktop file manager.
|
||||
|
||||
Uses the freedesktop org.freedesktop.FileManager1 interface (Nautilus,
|
||||
Dolphin, Nemo, …), which highlights the files in their folder. Falls back
|
||||
to just opening the containing folder if that service is unavailable.
|
||||
"""
|
||||
if not paths:
|
||||
return
|
||||
bus = QDBusConnection.sessionBus()
|
||||
if bus.isConnected():
|
||||
# The URIs must be marshalled as a D-Bus string array ("as"); a plain
|
||||
# Python list goes over as "av" and Nautilus rejects it (InvalidArgs).
|
||||
uris = QDBusArgument()
|
||||
uris.beginArray(QMetaType(QMetaType.Type.QString.value).id())
|
||||
for p in paths:
|
||||
uris.add(QUrl.fromLocalFile(p).toString())
|
||||
uris.endArray()
|
||||
iface = QDBusInterface(
|
||||
"org.freedesktop.FileManager1",
|
||||
"/org/freedesktop/FileManager1",
|
||||
"org.freedesktop.FileManager1",
|
||||
bus,
|
||||
)
|
||||
reply = iface.call("ShowItems", uris, "")
|
||||
if reply.type() != QDBusMessage.MessageType.ErrorMessage:
|
||||
return
|
||||
# No FileManager1 service (or it errored): open the folder instead.
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.dirname(paths[0])))
|
||||
|
||||
|
||||
class TrackTableModel(QAbstractTableModel):
|
||||
def __init__(self, parent=None, with_index_column=False):
|
||||
super().__init__(parent)
|
||||
self._tracks: list[Track] = []
|
||||
self._fields: list[str] = []
|
||||
self._with_index = with_index_column
|
||||
self._now_playing_id: int | None = None
|
||||
self._now_playing_active = False
|
||||
self.set_visible_columns(["name", "artist", "album"])
|
||||
|
||||
def set_now_playing(self, track_id: int | None, playing: bool):
|
||||
old_id = self._now_playing_id
|
||||
self._now_playing_id = track_id
|
||||
self._now_playing_active = playing
|
||||
for refresh_id in {old_id, track_id} - {None}:
|
||||
self.refresh_track(refresh_id)
|
||||
|
||||
@property
|
||||
def fields(self) -> list[str]:
|
||||
return self._fields
|
||||
|
||||
def set_visible_columns(self, columns: list[str]):
|
||||
self.beginResetModel()
|
||||
fields = [c for c in columns if c in COLUMN_MAP]
|
||||
if not fields:
|
||||
fields = ["name"]
|
||||
if self._with_index:
|
||||
fields = [INDEX_FIELD] + fields
|
||||
self._fields = fields
|
||||
self.endResetModel()
|
||||
|
||||
def set_tracks(self, tracks: list[Track]):
|
||||
self.beginResetModel()
|
||||
self._tracks = list(tracks)
|
||||
self.endResetModel()
|
||||
|
||||
def track_at(self, row: int) -> Track:
|
||||
return self._tracks[row]
|
||||
|
||||
@property
|
||||
def tracks(self) -> list[Track]:
|
||||
return self._tracks
|
||||
|
||||
def refresh_track(self, track_id: int):
|
||||
for row, track in enumerate(self._tracks):
|
||||
if track.track_id == track_id:
|
||||
self.dataChanged.emit(self.index(row, 0),
|
||||
self.index(row, self.columnCount() - 1))
|
||||
|
||||
def flags(self, index):
|
||||
# ItemIsDragEnabled is what lets the view initiate track drags
|
||||
return (Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
|
||||
| Qt.ItemFlag.ItemIsDragEnabled)
|
||||
|
||||
def rowCount(self, parent=QModelIndex()):
|
||||
return 0 if parent.isValid() else len(self._tracks)
|
||||
|
||||
def columnCount(self, parent=QModelIndex()):
|
||||
return 0 if parent.isValid() else len(self._fields)
|
||||
|
||||
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
|
||||
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
|
||||
field = self._fields[section]
|
||||
if field == INDEX_FIELD:
|
||||
return "#"
|
||||
return COLUMN_MAP[field][1]
|
||||
return None
|
||||
|
||||
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
|
||||
if not index.isValid():
|
||||
return None
|
||||
field = self._fields[index.column()]
|
||||
track = self._tracks[index.row()]
|
||||
|
||||
if field == INDEX_FIELD:
|
||||
if role == Qt.ItemDataRole.DisplayRole or role == SORT_ROLE:
|
||||
return index.row() + 1
|
||||
return None
|
||||
|
||||
if (role == Qt.ItemDataRole.DecorationRole and field == "name"
|
||||
and track.track_id == self._now_playing_id):
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
color = QApplication.palette().text().color()
|
||||
return speaker_pixmap(self._now_playing_active, color)
|
||||
|
||||
value = getattr(track, field, "")
|
||||
if role == SORT_ROLE:
|
||||
if isinstance(value, str):
|
||||
return value.lower()
|
||||
return value if value is not None else 0
|
||||
if role == Qt.ItemDataRole.DisplayRole:
|
||||
if field == "total_time":
|
||||
return format_time(value)
|
||||
if field == "rating":
|
||||
return _format_rating(value)
|
||||
if field == "size":
|
||||
return _format_size(value)
|
||||
if field in ("date_added", "play_date_utc", "skip_date", "date_modified"):
|
||||
return _format_date(value or "")
|
||||
if isinstance(value, int) and value == 0:
|
||||
return ""
|
||||
return str(value) if value else ""
|
||||
return None
|
||||
|
||||
|
||||
class TrackTableView(QTableView):
|
||||
"""Shared track list for the library and playlist views.
|
||||
|
||||
Owners connect to the signals and apply changes through LibraryManager;
|
||||
the view itself never mutates the library.
|
||||
"""
|
||||
|
||||
play_requested = pyqtSignal(list, int) # ids in view order, start index
|
||||
sort_changed = pyqtSignal(str, bool) # field, ascending
|
||||
columns_changed = pyqtSignal(list) # visible field names
|
||||
column_width_changed = pyqtSignal(str, int)
|
||||
reorder_requested = pyqtSignal(list, int) # manual rows, dest manual row
|
||||
tracks_dropped = pyqtSignal(list, object) # track ids, insert row or None
|
||||
files_dropped = pyqtSignal(list, object) # paths, insert row or None
|
||||
remove_requested = pyqtSignal(list) # manual rows
|
||||
paste_requested = pyqtSignal()
|
||||
info_requested = pyqtSignal(list) # selected track ids
|
||||
tracks_changed = pyqtSignal() # displayed track set changed
|
||||
show_in_playlist_requested = pyqtSignal(int, str) # track_id, playlist pid
|
||||
|
||||
def __init__(self, parent=None, playlist_mode=False):
|
||||
super().__init__(parent)
|
||||
self._playlist_mode = playlist_mode
|
||||
self._source_playlist_id = ""
|
||||
|
||||
self.model_ = TrackTableModel(self, with_index_column=playlist_mode)
|
||||
self.proxy = QSortFilterProxyModel(self)
|
||||
self.proxy.setSourceModel(self.model_)
|
||||
self.proxy.setSortRole(SORT_ROLE)
|
||||
self.setModel(self.proxy)
|
||||
|
||||
self.setAlternatingRowColors(True)
|
||||
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.setSortingEnabled(True)
|
||||
self.setShowGrid(False)
|
||||
self.verticalHeader().setVisible(False)
|
||||
self.verticalHeader().setDefaultSectionSize(22)
|
||||
self.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerItem)
|
||||
|
||||
header = self.horizontalHeader()
|
||||
header.setSectionsMovable(True)
|
||||
header.setStretchLastSection(True)
|
||||
# Don't bold header labels when a row/cell becomes current (playback
|
||||
# sets the current index, which would otherwise bold every section).
|
||||
header.setHighlightSections(False)
|
||||
header.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
header.customContextMenuRequested.connect(self._show_header_menu)
|
||||
header.sortIndicatorChanged.connect(self._on_sort_indicator)
|
||||
header.sectionResized.connect(self._on_section_resized)
|
||||
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(self._show_context_menu)
|
||||
self.doubleClicked.connect(self._on_double_click)
|
||||
|
||||
# Set by the owning view (which holds the manager) so the right-click
|
||||
# menu can list the playlists a track belongs to without the view
|
||||
# itself reaching into the library.
|
||||
self.playlists_for_track = None # Callable[[int], list[tuple[str,str]]]
|
||||
|
||||
# Drag and drop (default indicator off: we paint a glowing line)
|
||||
self.setDragEnabled(True)
|
||||
self.setAcceptDrops(True)
|
||||
self.setDropIndicatorShown(False)
|
||||
self._drop_indicator_y: int | None = None
|
||||
|
||||
QShortcut(QKeySequence.StandardKey.Copy, self,
|
||||
context=Qt.ShortcutContext.WidgetShortcut,
|
||||
activated=self.copy_selection)
|
||||
QShortcut(QKeySequence.StandardKey.Paste, self,
|
||||
context=Qt.ShortcutContext.WidgetShortcut,
|
||||
activated=self.paste_requested)
|
||||
|
||||
self._suppress_signals = False
|
||||
|
||||
# ---- configuration ----
|
||||
|
||||
def set_source_playlist(self, pid: str):
|
||||
self._source_playlist_id = pid
|
||||
|
||||
def context_id(self) -> str:
|
||||
"""Identifies what this table is currently showing, so the now-playing
|
||||
icon can be scoped to the context playback started from."""
|
||||
if self._playlist_mode:
|
||||
return f"playlist:{self._source_playlist_id}"
|
||||
return "library"
|
||||
|
||||
def reveal_track(self, track_id: int) -> bool:
|
||||
"""Scroll to and select the row for ``track_id``; False if not shown."""
|
||||
for src_row, track in enumerate(self.model_.tracks):
|
||||
if track.track_id == track_id:
|
||||
proxy_index = self.proxy.mapFromSource(
|
||||
self.model_.index(src_row, 0))
|
||||
self.setCurrentIndex(proxy_index)
|
||||
self.selectRow(proxy_index.row())
|
||||
self.scrollTo(
|
||||
proxy_index,
|
||||
QAbstractItemView.ScrollHint.PositionAtCenter)
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_tracks(self, tracks: list[Track]):
|
||||
self.model_.set_tracks(tracks)
|
||||
self.tracks_changed.emit()
|
||||
|
||||
def total_stats(self) -> tuple[int, int]:
|
||||
"""(track count, summed total_time in ms) of the displayed tracks."""
|
||||
tracks = self.model_.tracks
|
||||
return len(tracks), sum(t.total_time for t in tracks)
|
||||
|
||||
def apply_settings(self, settings):
|
||||
"""Apply a PlaylistSettings: columns, widths, sort."""
|
||||
self._suppress_signals = True
|
||||
try:
|
||||
self.model_.set_visible_columns(settings.visible_columns)
|
||||
self._apply_default_widths()
|
||||
for field, width in settings.column_widths.items():
|
||||
if field in self.model_.fields:
|
||||
self.setColumnWidth(self.model_.fields.index(field), width)
|
||||
sort_field = settings.sort_column
|
||||
if sort_field not in self.model_.fields:
|
||||
sort_field = self.model_.fields[0]
|
||||
order = (Qt.SortOrder.AscendingOrder if settings.sort_ascending
|
||||
else Qt.SortOrder.DescendingOrder)
|
||||
self.sortByColumn(self.model_.fields.index(sort_field), order)
|
||||
finally:
|
||||
self._suppress_signals = False
|
||||
|
||||
def _apply_default_widths(self):
|
||||
for i, field in enumerate(self.model_.fields):
|
||||
if field == INDEX_FIELD:
|
||||
self.setColumnWidth(i, 50)
|
||||
else:
|
||||
self.setColumnWidth(i, COLUMN_MAP[field][2])
|
||||
|
||||
# ---- selection / ordering helpers ----
|
||||
|
||||
def view_order_track_ids(self) -> list[int]:
|
||||
ids = []
|
||||
for proxy_row in range(self.proxy.rowCount()):
|
||||
source_row = self.proxy.mapToSource(self.proxy.index(proxy_row, 0)).row()
|
||||
ids.append(self.model_.track_at(source_row).track_id)
|
||||
return ids
|
||||
|
||||
def selected_source_rows(self) -> list[int]:
|
||||
rows = {self.proxy.mapToSource(idx).row()
|
||||
for idx in self.selectionModel().selectedRows()}
|
||||
return sorted(rows)
|
||||
|
||||
def selected_track_ids(self) -> list[int]:
|
||||
return [self.model_.track_at(r).track_id for r in self.selected_source_rows()]
|
||||
|
||||
def selected_locations(self) -> list[str]:
|
||||
"""File paths of the selected tracks, skipping any without a location."""
|
||||
locs = [self.model_.track_at(r).location
|
||||
for r in self.selected_source_rows()]
|
||||
return [loc for loc in locs if loc]
|
||||
|
||||
def is_manual_sort(self) -> bool:
|
||||
header = self.horizontalHeader()
|
||||
return (self._playlist_mode
|
||||
and self.model_.fields
|
||||
and self.model_.fields[header.sortIndicatorSection()] == INDEX_FIELD
|
||||
and header.sortIndicatorOrder() == Qt.SortOrder.AscendingOrder)
|
||||
|
||||
# ---- copy / paste ----
|
||||
|
||||
def copy_selection(self):
|
||||
ids = self.selected_track_ids()
|
||||
if not ids:
|
||||
return
|
||||
mime = make_tracks_mime(ids, self._source_playlist_id,
|
||||
self.selected_source_rows())
|
||||
QApplication.clipboard().setMimeData(mime)
|
||||
|
||||
# ---- header interactions ----
|
||||
|
||||
def _on_sort_indicator(self, section, order):
|
||||
if self._suppress_signals or not self.model_.fields:
|
||||
return
|
||||
field = self.model_.fields[section]
|
||||
self.sort_changed.emit(field, order == Qt.SortOrder.AscendingOrder)
|
||||
|
||||
def _on_section_resized(self, section, _old, new_width):
|
||||
if self._suppress_signals or section >= len(self.model_.fields):
|
||||
return
|
||||
field = self.model_.fields[section]
|
||||
if field != INDEX_FIELD:
|
||||
self.column_width_changed.emit(field, new_width)
|
||||
|
||||
def _show_header_menu(self, pos):
|
||||
menu = QMenu(self)
|
||||
visible = [f for f in self.model_.fields if f != INDEX_FIELD]
|
||||
for field, label, _w in ALL_COLUMNS:
|
||||
action = menu.addAction(label)
|
||||
action.setCheckable(True)
|
||||
action.setChecked(field in visible)
|
||||
action.setData(field)
|
||||
chosen = menu.exec(self.horizontalHeader().mapToGlobal(pos))
|
||||
if chosen is None:
|
||||
return
|
||||
field = chosen.data()
|
||||
if chosen.isChecked():
|
||||
new_visible = visible + [field]
|
||||
# Preserve canonical column ordering
|
||||
new_visible = [f for f, _l, _w in ALL_COLUMNS if f in new_visible]
|
||||
else:
|
||||
new_visible = [f for f in visible if f != field]
|
||||
if not new_visible:
|
||||
return
|
||||
self.columns_changed.emit(new_visible)
|
||||
|
||||
# ---- context menu ----
|
||||
|
||||
def _show_context_menu(self, pos):
|
||||
if not self.selected_track_ids():
|
||||
return
|
||||
menu = QMenu(self)
|
||||
info_action = menu.addAction("Get Info\tCtrl+I")
|
||||
copy_action = menu.addAction("Copy\tCtrl+C")
|
||||
|
||||
locations = self.selected_locations()
|
||||
reveal_action = copy_path_action = None
|
||||
if locations:
|
||||
menu.addSeparator()
|
||||
reveal_action = menu.addAction("Reveal in File Browser")
|
||||
copy_path_action = menu.addAction("Copy File Path")
|
||||
|
||||
# "Show in Playlist" — only for a single track, listing the regular
|
||||
# playlists it belongs to. Hovering reveals the submenu; choosing an
|
||||
# entry jumps to that playlist and highlights the first instance.
|
||||
show_in_menu = None
|
||||
selected = self.selected_track_ids()
|
||||
if len(selected) == 1 and self.playlists_for_track is not None:
|
||||
entries = self.playlists_for_track(selected[0])
|
||||
if entries:
|
||||
menu.addSeparator()
|
||||
show_in_menu = menu.addMenu("Show in Playlist")
|
||||
for pid, name in entries:
|
||||
show_in_menu.addAction(name).setData(pid)
|
||||
|
||||
remove_action = None
|
||||
if self._playlist_mode:
|
||||
menu.addSeparator()
|
||||
remove_action = menu.addAction("Remove from Playlist\tDel")
|
||||
chosen = menu.exec(self.viewport().mapToGlobal(pos))
|
||||
if chosen is None:
|
||||
return
|
||||
if chosen is info_action:
|
||||
self.info_requested.emit(self.selected_track_ids())
|
||||
elif chosen is copy_action:
|
||||
self.copy_selection()
|
||||
elif chosen is reveal_action:
|
||||
reveal_paths(locations)
|
||||
elif chosen is copy_path_action:
|
||||
QApplication.clipboard().setText("\n".join(locations))
|
||||
elif show_in_menu is not None and chosen in show_in_menu.actions():
|
||||
self.show_in_playlist_requested.emit(selected[0], chosen.data())
|
||||
elif remove_action is not None and chosen is remove_action:
|
||||
self.remove_requested.emit(self.selected_source_rows())
|
||||
|
||||
# ---- playback ----
|
||||
|
||||
def _on_double_click(self, index):
|
||||
ids = self.view_order_track_ids()
|
||||
if ids:
|
||||
self.play_requested.emit(ids, index.row())
|
||||
|
||||
# ---- keyboard ----
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
if (event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace)
|
||||
and self._playlist_mode and self.selected_source_rows()):
|
||||
self.remove_requested.emit(self.selected_source_rows())
|
||||
return
|
||||
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
||||
current = self.currentIndex()
|
||||
if current.isValid():
|
||||
self._on_double_click(current)
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
# ---- drag and drop ----
|
||||
|
||||
def startDrag(self, supported_actions):
|
||||
ids = self.selected_track_ids()
|
||||
if not ids:
|
||||
return
|
||||
drag = QDrag(self)
|
||||
drag.setMimeData(make_tracks_mime(ids, self._source_playlist_id,
|
||||
self.selected_source_rows()))
|
||||
# Mutter/Wayland won't render QDrag.setPixmap, so we drive our own
|
||||
# cursor-following overlay (drag_ghost) instead.
|
||||
pixmap, hotspot = self._drag_pixmap(ids)
|
||||
drag_ghost.begin(self.window(), pixmap, hotspot, QCursor.pos())
|
||||
try:
|
||||
drag.exec(Qt.DropAction.CopyAction | Qt.DropAction.MoveAction)
|
||||
finally:
|
||||
drag_ghost.end()
|
||||
|
||||
def _drag_pixmap(self, ids: list[int]) -> tuple[QPixmap, QPoint]:
|
||||
"""A song-file icon plus a rounded chip naming the dragged track(s).
|
||||
|
||||
Returns the composed pixmap and the hotspot (the icon's centre) so the
|
||||
icon sits directly under the cursor with the name chip trailing right.
|
||||
"""
|
||||
name = self.model_.track_at(self.selected_source_rows()[0]).name
|
||||
text = name or "(untitled)"
|
||||
if len(ids) > 1:
|
||||
text += f" +{len(ids) - 1} more"
|
||||
metrics = QFontMetrics(self.font())
|
||||
text = metrics.elidedText(text, Qt.TextElideMode.ElideRight, 260)
|
||||
pad_x = 10
|
||||
chip_w = metrics.horizontalAdvance(text) + 2 * pad_x
|
||||
h = metrics.height() + 10
|
||||
icon_size = h
|
||||
gap = 6
|
||||
w = icon_size + gap + chip_w
|
||||
ratio = self.devicePixelRatioF()
|
||||
pixmap = QPixmap(int(w * ratio), int(h * ratio))
|
||||
pixmap.setDevicePixelRatio(ratio)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
|
||||
painter.drawPixmap(0, 0, drag_ghost.song_icon(icon_size, ratio))
|
||||
chip_x = icon_size + gap
|
||||
background = QColor(self.palette().highlight().color())
|
||||
background.setAlpha(230)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(background)
|
||||
painter.drawRoundedRect(QRectF(chip_x, 0, chip_w, h), h / 2, h / 2)
|
||||
painter.setPen(self.palette().highlightedText().color())
|
||||
painter.setFont(self.font())
|
||||
painter.drawText(QRectF(chip_x + pad_x, 0, chip_w - 2 * pad_x, h),
|
||||
Qt.AlignmentFlag.AlignVCenter, text)
|
||||
painter.end()
|
||||
return pixmap, QPoint(icon_size // 2, h // 2)
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
if event.mimeData().hasFormat(TRACKS_MIME) or event.mimeData().hasUrls():
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
event.ignore()
|
||||
|
||||
def dragMoveEvent(self, event):
|
||||
drag_ghost.move(self.viewport().mapToGlobal(event.position().toPoint()))
|
||||
mime = event.mimeData()
|
||||
accepted = False
|
||||
if mime.hasUrls():
|
||||
accepted = True
|
||||
else:
|
||||
payload = parse_tracks_mime(mime)
|
||||
if payload is None:
|
||||
accepted = False
|
||||
elif (self._playlist_mode and self._source_playlist_id
|
||||
and payload.get("source_playlist") == self._source_playlist_id):
|
||||
# reorder only allowed in manual order
|
||||
accepted = self.is_manual_sort()
|
||||
else:
|
||||
# library view is not a drop target for tracks
|
||||
accepted = self._playlist_mode
|
||||
if accepted:
|
||||
event.acceptProposedAction()
|
||||
self._set_drop_indicator(
|
||||
self._indicator_y(event.position().toPoint()))
|
||||
else:
|
||||
event.ignore()
|
||||
self._set_drop_indicator(None)
|
||||
|
||||
def dragLeaveEvent(self, event):
|
||||
self._set_drop_indicator(None)
|
||||
super().dragLeaveEvent(event)
|
||||
|
||||
def dropEvent(self, event):
|
||||
self._set_drop_indicator(None)
|
||||
mime = event.mimeData()
|
||||
drop_row = self._drop_row(event.position().toPoint())
|
||||
if mime.hasUrls():
|
||||
paths = [u.toLocalFile() for u in mime.urls() if u.isLocalFile()]
|
||||
if paths:
|
||||
self.files_dropped.emit(paths, drop_row)
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
payload = parse_tracks_mime(mime)
|
||||
if payload is None or not self._playlist_mode:
|
||||
return
|
||||
same_playlist = (self._source_playlist_id
|
||||
and payload.get("source_playlist") == self._source_playlist_id)
|
||||
if same_playlist:
|
||||
if self.is_manual_sort():
|
||||
dest = drop_row if drop_row is not None else self.model_.rowCount()
|
||||
self.reorder_requested.emit(payload.get("rows", []), dest)
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
self.tracks_dropped.emit(payload.get("track_ids", []), drop_row)
|
||||
event.acceptProposedAction()
|
||||
|
||||
def _drop_row(self, pos) -> int | None:
|
||||
"""Manual-order row to insert before, or None to append.
|
||||
|
||||
Only meaningful when the view order equals manual order; otherwise
|
||||
callers should append.
|
||||
"""
|
||||
index = self.indexAt(pos)
|
||||
if not index.isValid():
|
||||
return None
|
||||
if not self.is_manual_sort():
|
||||
return None
|
||||
rect = self.visualRect(index)
|
||||
row = index.row()
|
||||
if pos.y() > rect.center().y():
|
||||
row += 1
|
||||
return row
|
||||
|
||||
# ---- drop indicator ----
|
||||
|
||||
def _indicator_y(self, pos) -> int:
|
||||
"""Viewport y for the insertion line matching where the drop lands."""
|
||||
count = self.proxy.rowCount()
|
||||
if count == 0:
|
||||
return 0
|
||||
row = self._drop_row(pos)
|
||||
if row is None: # drop appends
|
||||
row = count
|
||||
if row < count:
|
||||
return self.visualRect(self.proxy.index(row, 0)).top()
|
||||
return self.visualRect(self.proxy.index(count - 1, 0)).bottom() + 1
|
||||
|
||||
def _set_drop_indicator(self, y: int | None):
|
||||
if y != self._drop_indicator_y:
|
||||
self._drop_indicator_y = y
|
||||
self.viewport().update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
super().paintEvent(event)
|
||||
if self._drop_indicator_y is None:
|
||||
return
|
||||
y = max(1, min(self._drop_indicator_y, self.viewport().height() - 2))
|
||||
right = self.viewport().width() - 4
|
||||
painter = QPainter(self.viewport())
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
color = QColor(self.palette().highlight().color())
|
||||
glow = QColor(color)
|
||||
glow.setAlpha(70)
|
||||
painter.setPen(QPen(glow, 6, Qt.PenStyle.SolidLine,
|
||||
Qt.PenCapStyle.RoundCap))
|
||||
painter.drawLine(4, y, right, y)
|
||||
painter.setPen(QPen(color, 2))
|
||||
painter.drawLine(4, y, right, y)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(color)
|
||||
painter.drawEllipse(QPointF(4, y), 3, 3)
|
||||
painter.drawEllipse(QPointF(right, y), 3, 3)
|
||||
@@ -0,0 +1,360 @@
|
||||
import time
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QFrame, QHBoxLayout, QVBoxLayout, QLabel, QToolButton, QSlider,
|
||||
QPushButton, QSizePolicy, QStyle,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
|
||||
from PyQt6.QtGui import QColor, QFont
|
||||
|
||||
from lintunes.tap_tempo import TapTempo
|
||||
from lintunes.gui.icons import transport_icon
|
||||
from lintunes.gui.track_table import format_time
|
||||
from lintunes.gui.visualizer import VisualizerWidget
|
||||
from lintunes import theme
|
||||
|
||||
|
||||
ICON_GRAY = QColor("#4A4A4A")
|
||||
|
||||
# Height floor for the top control panel. It must exceed the natural stacked
|
||||
# height of title + artist + timeline (~69px at medium scale) so the leftover
|
||||
# becomes vertical slack that the equal stretches split into matching borders
|
||||
# above the title and below the artist. Bigger = more breathing room (and a
|
||||
# taller bar); the text block stays centered either way.
|
||||
BAR_HEIGHT = 84
|
||||
|
||||
# Shared height for the boxed side controls (transport, shuffle, bpm) and the
|
||||
# visualizer, so they line up. ~midway between their old heights: the stretchy
|
||||
# boxes/visualizer used to fill the bar (~78px) while the hugging bpm box sat
|
||||
# at ~46px. Tune to taste.
|
||||
CONTROL_HEIGHT = 62
|
||||
|
||||
_BOX_STYLE = """
|
||||
QFrame#transportBox {
|
||||
border: 1px solid palette(mid);
|
||||
border-radius: 8px;
|
||||
background: palette(alternate-base);
|
||||
}
|
||||
"""
|
||||
|
||||
_NOWPLAYING_STYLE = """
|
||||
QFrame#nowPlayingBox {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
}
|
||||
"""
|
||||
|
||||
# Title sits this many points above the artist/album line, both following the
|
||||
# UI-scale app font (see theme.UI_SCALES).
|
||||
TITLE_PT_BUMP = 2
|
||||
|
||||
|
||||
class FlashButton(QToolButton):
|
||||
"""Transport button: dark-gray glyph that shows the highlight color
|
||||
while pressed (a quick flash on click, sustained while held). A checkable
|
||||
button (shuffle) keeps the highlight color while checked."""
|
||||
|
||||
def __init__(self, kind: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self._kind = kind
|
||||
self.setAutoRaise(True)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.pressed.connect(lambda: self._apply_icon(active=True))
|
||||
self.released.connect(lambda: self._apply_icon())
|
||||
self.toggled.connect(lambda _: self._apply_icon())
|
||||
self.refresh_icons()
|
||||
|
||||
def set_kind(self, kind: str):
|
||||
if kind != self._kind:
|
||||
self._kind = kind
|
||||
self._apply_icon(active=self.isDown())
|
||||
|
||||
def refresh_icons(self):
|
||||
self._apply_icon(active=self.isDown())
|
||||
|
||||
def _apply_icon(self, active: bool = False):
|
||||
show_active = active or (self.isCheckable() and self.isChecked())
|
||||
color = self.palette().highlight().color() if show_active else ICON_GRAY
|
||||
self.setIcon(transport_icon(self._kind, color))
|
||||
|
||||
|
||||
def _box(*widgets, margins=(10, 3, 10, 3), hug=False, height=None) -> QFrame:
|
||||
"""A small rounded frame with its content centered inside.
|
||||
|
||||
With hug=True the frame stays at its size hint instead of stretching
|
||||
to fill the row it sits in. height pins the frame's height (width is
|
||||
left untouched), so several boxes can be lined up to the same height."""
|
||||
frame = QFrame()
|
||||
frame.setObjectName("transportBox")
|
||||
frame.setStyleSheet(_BOX_STYLE)
|
||||
inner = QHBoxLayout(frame)
|
||||
inner.setContentsMargins(*margins)
|
||||
inner.addStretch()
|
||||
for widget in widgets:
|
||||
inner.addWidget(widget)
|
||||
inner.addStretch()
|
||||
if height is not None:
|
||||
frame.setFixedHeight(height)
|
||||
if hug:
|
||||
frame.setSizePolicy(QSizePolicy.Policy.Maximum,
|
||||
QSizePolicy.Policy.Maximum)
|
||||
return frame
|
||||
|
||||
|
||||
class SeekSlider(QSlider):
|
||||
"""Timeline slider: clicking anywhere jumps the handle (and playhead)
|
||||
straight to the clicked spot instead of paging; dragging scrubs."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(Qt.Orientation.Horizontal, parent)
|
||||
|
||||
def _value_at(self, x: float) -> int:
|
||||
return QStyle.sliderValueFromPosition(
|
||||
self.minimum(), self.maximum(), int(x), self.width())
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton and self.maximum() > 0:
|
||||
self.setSliderDown(True) # emits sliderPressed
|
||||
self.setSliderPosition(self._value_at(event.position().x()))
|
||||
event.accept()
|
||||
return
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if self.isSliderDown():
|
||||
self.setSliderPosition(self._value_at(event.position().x()))
|
||||
event.accept()
|
||||
return
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton and self.isSliderDown():
|
||||
self.setSliderDown(False) # emits sliderReleased
|
||||
event.accept()
|
||||
return
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
|
||||
class BpmButton(QPushButton):
|
||||
"""Tap-tempo button: tap along to the music; when the cursor leaves,
|
||||
the shown bpm is saved to the playing track 3 seconds later."""
|
||||
|
||||
SAVE_DELAY_MS = 3000
|
||||
|
||||
def __init__(self, player, manager, parent=None):
|
||||
super().__init__("bpm", parent)
|
||||
self._player = player
|
||||
self._manager = manager
|
||||
self._tempo = TapTempo()
|
||||
self._target_track_id: int | None = None
|
||||
self.setFixedWidth(64)
|
||||
self.setFlat(True) # flat text inside its box, like the transport icons
|
||||
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.setEnabled(False)
|
||||
self.setToolTip("Tap along to set this track's BPM")
|
||||
|
||||
self._save_timer = QTimer(self)
|
||||
self._save_timer.setSingleShot(True)
|
||||
self._save_timer.setInterval(self.SAVE_DELAY_MS)
|
||||
self._save_timer.timeout.connect(self._save_bpm)
|
||||
|
||||
self.clicked.connect(self._on_tap)
|
||||
player.track_changed.connect(self._on_track_changed)
|
||||
|
||||
def _on_track_changed(self, track):
|
||||
# Track switched: any in-progress tap series no longer applies
|
||||
self._reset()
|
||||
self.setEnabled(track is not None)
|
||||
|
||||
def _on_tap(self):
|
||||
self._save_timer.stop()
|
||||
track = self._player.current_track
|
||||
if track is None:
|
||||
return
|
||||
if self._tempo.tap_count == 0:
|
||||
self._target_track_id = track.track_id
|
||||
bpm = self._tempo.tap(time.monotonic())
|
||||
self.setText(str(bpm) if bpm is not None else "...")
|
||||
|
||||
def leaveEvent(self, event):
|
||||
if self._tempo.tap_count >= 2 and self._tempo.bpm() is not None:
|
||||
self._save_timer.start()
|
||||
super().leaveEvent(event)
|
||||
|
||||
def _save_bpm(self):
|
||||
bpm = self._tempo.bpm()
|
||||
track = (self._manager.library.tracks.get(self._target_track_id)
|
||||
if self._target_track_id is not None else None)
|
||||
if bpm is not None and track is not None:
|
||||
# Writes the bpm tag to the file and records it for undo.
|
||||
self._manager.edit_track_fields(track.track_id, {"bpm": bpm})
|
||||
self._reset()
|
||||
|
||||
def _reset(self):
|
||||
self._save_timer.stop()
|
||||
self._tempo.reset()
|
||||
self._target_track_id = None
|
||||
self.setText("bpm")
|
||||
|
||||
|
||||
class TransportBar(QWidget):
|
||||
"""Top bar: boxed transport buttons and shuffle (left), visualizer,
|
||||
now-playing text, seek slider, boxed bpm tap button (right)."""
|
||||
|
||||
play_clicked = pyqtSignal() # MainWindow decides what "play" means
|
||||
|
||||
def __init__(self, player, manager, prefs, parent=None):
|
||||
super().__init__(parent)
|
||||
self._player = player
|
||||
self._prefs = prefs
|
||||
self._scrubbing = False
|
||||
self._current_track = None
|
||||
|
||||
self.setMinimumHeight(BAR_HEIGHT)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(8, 3, 8, 3)
|
||||
|
||||
self._prev_btn = FlashButton("previous")
|
||||
self._play_btn = FlashButton("play")
|
||||
self._next_btn = FlashButton("next")
|
||||
layout.addWidget(_box(self._prev_btn, self._play_btn, self._next_btn,
|
||||
height=CONTROL_HEIGHT))
|
||||
|
||||
self._shuffle_btn = FlashButton("shuffle")
|
||||
self._shuffle_btn.setCheckable(True)
|
||||
self._shuffle_btn.setToolTip("Shuffle (doesn't change playlist order)")
|
||||
layout.addWidget(_box(self._shuffle_btn, height=CONTROL_HEIGHT))
|
||||
|
||||
layout.addSpacing(10)
|
||||
self._visualizer = VisualizerWidget(player)
|
||||
self._visualizer.setFixedHeight(CONTROL_HEIGHT)
|
||||
layout.addWidget(self._visualizer)
|
||||
layout.addSpacing(10)
|
||||
|
||||
center = QVBoxLayout()
|
||||
center.setSpacing(0)
|
||||
|
||||
# White rounded now-playing panel: holds only the song title and the
|
||||
# artist/album line, centered, filling the zone above the seek row.
|
||||
self._now_playing = QFrame()
|
||||
self._now_playing.setObjectName("nowPlayingBox")
|
||||
self._now_playing.setStyleSheet(_NOWPLAYING_STYLE)
|
||||
np = QVBoxLayout(self._now_playing)
|
||||
np.setContentsMargins(12, 6, 12, 6)
|
||||
np.setSpacing(2)
|
||||
np.addStretch(1)
|
||||
|
||||
self._title_label = QLabel("")
|
||||
self._title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
np.addWidget(self._title_label)
|
||||
|
||||
self._artist_label = QLabel("")
|
||||
self._artist_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
np.addWidget(self._artist_label)
|
||||
|
||||
np.addStretch(1)
|
||||
self._apply_now_playing_font()
|
||||
|
||||
seek_row = QHBoxLayout()
|
||||
self._elapsed_label = QLabel("")
|
||||
self._elapsed_label.setFixedWidth(50)
|
||||
self._remaining_label = QLabel("")
|
||||
self._remaining_label.setFixedWidth(50)
|
||||
self._remaining_label.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self._slider = SeekSlider()
|
||||
self._slider.setRange(0, 0)
|
||||
self._slider.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
seek_row.addWidget(self._elapsed_label)
|
||||
seek_row.addWidget(self._slider)
|
||||
seek_row.addWidget(self._remaining_label)
|
||||
|
||||
# The now-playing panel fills the zone above the seek row; the seek row
|
||||
# stays pinned to the bottom so the timeline sits low. Its height is
|
||||
# just the slider + time labels, so the panel takes the rest.
|
||||
center.addWidget(self._now_playing, stretch=1)
|
||||
center.addLayout(seek_row)
|
||||
layout.addLayout(center, stretch=1)
|
||||
|
||||
# bpm tap button lives on the far right as its own box, vertically
|
||||
# centered like the transport boxes — kept out of the seek row so it
|
||||
# doesn't drive the timeline row's height.
|
||||
self._bpm_button = BpmButton(player, manager)
|
||||
layout.addWidget(_box(self._bpm_button, margins=(8, 8, 8, 8), hug=True,
|
||||
height=CONTROL_HEIGHT))
|
||||
|
||||
# Wiring
|
||||
self._prev_btn.clicked.connect(player.previous)
|
||||
self._play_btn.clicked.connect(self.play_clicked)
|
||||
self._next_btn.clicked.connect(player.next)
|
||||
self._shuffle_btn.toggled.connect(player.set_shuffle)
|
||||
self._slider.sliderPressed.connect(self._on_scrub_start)
|
||||
self._slider.sliderReleased.connect(self._on_scrub_end)
|
||||
self._slider.sliderMoved.connect(self._on_scrub_move)
|
||||
player.track_changed.connect(self._on_track_changed)
|
||||
player.playing_changed.connect(self._on_playing_changed)
|
||||
player.position_changed.connect(self._on_position_changed)
|
||||
player.duration_changed.connect(self._on_duration_changed)
|
||||
|
||||
def refresh_theme(self):
|
||||
"""Rebuild button glyphs after a highlight-color or font change."""
|
||||
for button in (self._prev_btn, self._play_btn, self._next_btn,
|
||||
self._shuffle_btn):
|
||||
button.refresh_icons()
|
||||
self._apply_now_playing_font()
|
||||
|
||||
def _apply_now_playing_font(self):
|
||||
"""Set the now-playing labels to Century Gothic (or the user's chosen
|
||||
substitute), black text; title slightly larger than artist/album.
|
||||
Sizes track the app font so they follow the UI-scale preference."""
|
||||
family = theme.now_playing_font_family(self._prefs)
|
||||
base_pt = self.font().pointSize()
|
||||
for label, delta in ((self._title_label, TITLE_PT_BUMP),
|
||||
(self._artist_label, 0)):
|
||||
font = QFont(family) if family else QFont(self.font())
|
||||
font.setPointSize(max(1, base_pt + delta))
|
||||
label.setFont(font)
|
||||
label.setStyleSheet("color: black;")
|
||||
|
||||
# ---- player events ----
|
||||
|
||||
def _on_track_changed(self, track):
|
||||
self._current_track = track
|
||||
if track is None:
|
||||
self._title_label.setText("")
|
||||
self._artist_label.setText("")
|
||||
self._slider.setRange(0, 0)
|
||||
self._elapsed_label.setText("")
|
||||
self._remaining_label.setText("")
|
||||
return
|
||||
self._title_label.setText(track.name)
|
||||
parts = [p for p in (track.artist, track.album) if p]
|
||||
self._artist_label.setText(" — ".join(parts))
|
||||
|
||||
def _on_playing_changed(self, playing):
|
||||
self._play_btn.set_kind("pause" if playing else "play")
|
||||
|
||||
def _on_position_changed(self, position):
|
||||
if not self._scrubbing:
|
||||
self._slider.setValue(position)
|
||||
self._update_time_labels(position)
|
||||
|
||||
def _on_duration_changed(self, duration):
|
||||
self._slider.setRange(0, max(0, duration))
|
||||
|
||||
def _update_time_labels(self, position):
|
||||
duration = self._slider.maximum()
|
||||
self._elapsed_label.setText(format_time(position) or "0:00")
|
||||
if duration > 0:
|
||||
self._remaining_label.setText("-" + (format_time(duration - position) or "0:00"))
|
||||
|
||||
# ---- scrubbing ----
|
||||
|
||||
def _on_scrub_start(self):
|
||||
self._scrubbing = True
|
||||
|
||||
def _on_scrub_move(self, value):
|
||||
self._update_time_labels(value)
|
||||
|
||||
def _on_scrub_end(self):
|
||||
self._scrubbing = False
|
||||
self._player.seek(self._slider.value())
|
||||
@@ -0,0 +1,187 @@
|
||||
"""20-band graphic-EQ visualizer fed by the player's decoded PCM tee.
|
||||
|
||||
The player's QAudioBufferOutput delivers buffers essentially in lockstep
|
||||
with the audible position (verified: frames received ≈ frames played), so
|
||||
no extra sync buffering is needed — we just FFT the newest samples.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
from PyQt6.QtWidgets import QWidget
|
||||
from PyQt6.QtCore import Qt, QTimer, QRectF
|
||||
from PyQt6.QtGui import QPainter, QColor, QPainterPath, QPen
|
||||
from PyQt6.QtMultimedia import QAudioFormat
|
||||
|
||||
|
||||
BANDS = 20
|
||||
STEPS = 20 # vertical granularity per bar
|
||||
FFT_SIZE = 2048
|
||||
FRAME_MS = 16 # ~60 fps
|
||||
DECAY = 0.82 # slow decay; attack is instant
|
||||
FLOOR_DB = -55.0
|
||||
RADIUS = 8 # rounded corners, matching the transport button boxes
|
||||
|
||||
# Visualizer click cycles through these brightness modes in order.
|
||||
MODE_ON = "on"
|
||||
MODE_DIM = "dim"
|
||||
MODE_OFF = "off"
|
||||
_MODE_CYCLE = (MODE_ON, MODE_DIM, MODE_OFF)
|
||||
|
||||
_SAMPLE_DTYPES = {
|
||||
QAudioFormat.SampleFormat.UInt8: (np.uint8, 127.5, 127.5),
|
||||
QAudioFormat.SampleFormat.Int16: (np.int16, 0.0, 32768.0),
|
||||
QAudioFormat.SampleFormat.Int32: (np.int32, 0.0, 2147483648.0),
|
||||
QAudioFormat.SampleFormat.Float: (np.float32, 0.0, 1.0),
|
||||
}
|
||||
|
||||
|
||||
class VisualizerWidget(QWidget):
|
||||
"""Animated spectrum bars; click to cycle brightness: on / dim / off.
|
||||
|
||||
On = highlight-colored bars; dim = light-gray bars just darker than the
|
||||
panel; off = blank.
|
||||
"""
|
||||
|
||||
def __init__(self, player, parent=None):
|
||||
super().__init__(parent)
|
||||
self._player = player
|
||||
self._mode = MODE_ON
|
||||
self._levels = np.zeros(BANDS)
|
||||
self._bars = [0] * BANDS # quantized 0..STEPS
|
||||
self._samples = np.zeros(FFT_SIZE, dtype=np.float32)
|
||||
self._sample_rate = 44100
|
||||
self._window = np.hanning(FFT_SIZE).astype(np.float32)
|
||||
|
||||
self.setFixedWidth(140)
|
||||
self.setMinimumHeight(36)
|
||||
self.setToolTip("Click to cycle: on / dim / off")
|
||||
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(FRAME_MS)
|
||||
self._timer.timeout.connect(self._advance_frame)
|
||||
|
||||
player.audio_buffer.connect(self._on_buffer)
|
||||
player.playing_changed.connect(self._on_playing_changed)
|
||||
player.track_changed.connect(self._on_track_changed)
|
||||
|
||||
# ---- audio intake ----
|
||||
|
||||
def _on_buffer(self, buf):
|
||||
fmt = buf.format()
|
||||
spec = _SAMPLE_DTYPES.get(fmt.sampleFormat())
|
||||
if spec is None:
|
||||
return
|
||||
dtype, offset, scale = spec
|
||||
pointer = buf.data()
|
||||
pointer.setsize(buf.byteCount())
|
||||
raw = np.frombuffer(pointer, dtype=dtype)
|
||||
channels = max(1, fmt.channelCount())
|
||||
if channels > 1:
|
||||
usable = (len(raw) // channels) * channels
|
||||
raw = raw[:usable].reshape(-1, channels).mean(axis=1)
|
||||
mono = (raw.astype(np.float32) - offset) / scale
|
||||
self._sample_rate = fmt.sampleRate() or 44100
|
||||
|
||||
if len(mono) >= FFT_SIZE:
|
||||
self._samples = mono[-FFT_SIZE:].copy()
|
||||
else:
|
||||
self._samples = np.roll(self._samples, -len(mono))
|
||||
self._samples[-len(mono):] = mono
|
||||
|
||||
# ---- state ----
|
||||
|
||||
def _on_playing_changed(self, playing):
|
||||
if playing:
|
||||
if self._mode != MODE_OFF:
|
||||
self._timer.start()
|
||||
else:
|
||||
self._timer.stop()
|
||||
if self._mode != MODE_OFF:
|
||||
self._clear()
|
||||
|
||||
def _on_track_changed(self, track):
|
||||
self._samples[:] = 0.0
|
||||
if track is None and self._mode != MODE_OFF:
|
||||
self._timer.stop()
|
||||
self._clear()
|
||||
|
||||
def _clear(self):
|
||||
self._levels[:] = 0.0
|
||||
self._bars = [0] * BANDS
|
||||
self.update()
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
# Cycle on -> dim -> off -> on.
|
||||
idx = _MODE_CYCLE.index(self._mode)
|
||||
self._mode = _MODE_CYCLE[(idx + 1) % len(_MODE_CYCLE)]
|
||||
if self._mode == MODE_OFF:
|
||||
self._timer.stop()
|
||||
self._clear() # off = blank, not the last frame held in place
|
||||
elif self._player.is_playing():
|
||||
self._timer.start()
|
||||
|
||||
# ---- frame computation ----
|
||||
|
||||
def _advance_frame(self):
|
||||
spectrum = np.abs(np.fft.rfft(self._samples * self._window))
|
||||
# Normalize: full-scale sine -> ~1.0
|
||||
spectrum /= (FFT_SIZE / 4)
|
||||
|
||||
nyquist = self._sample_rate / 2
|
||||
top = min(16000.0, nyquist * 0.95)
|
||||
edges = np.geomspace(40.0, max(top, 80.0), BANDS + 1)
|
||||
freqs = np.fft.rfftfreq(FFT_SIZE, d=1.0 / self._sample_rate)
|
||||
|
||||
new_levels = np.zeros(BANDS)
|
||||
for i in range(BANDS):
|
||||
mask = (freqs >= edges[i]) & (freqs < edges[i + 1])
|
||||
if mask.any():
|
||||
magnitude = spectrum[mask].max()
|
||||
db = 20.0 * np.log10(magnitude + 1e-10)
|
||||
new_levels[i] = np.clip(1.0 - db / FLOOR_DB, 0.0, 1.0)
|
||||
|
||||
# Instant attack, slow decay
|
||||
self._levels = np.maximum(new_levels, self._levels * DECAY)
|
||||
bars = [int(round(level * STEPS)) for level in self._levels]
|
||||
if bars != self._bars:
|
||||
self._bars = bars
|
||||
self.update()
|
||||
|
||||
# ---- painting ----
|
||||
|
||||
def _bar_color(self):
|
||||
if self._mode == MODE_DIM:
|
||||
# Light gray just darker than the panel: take alternateBase and
|
||||
# drop its lightness ~12% so it tracks theme/scale changes.
|
||||
base = QColor(self.palette().alternateBase().color())
|
||||
h, s, l, a = base.getHslF()
|
||||
base.setHslF(h, s, l * 0.88, a)
|
||||
return base
|
||||
return QColor(self.palette().highlight().color())
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
# Rounded gray panel + 1px border, matching the transport button boxes.
|
||||
rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
|
||||
panel = QPainterPath()
|
||||
panel.addRoundedRect(rect, RADIUS, RADIUS)
|
||||
painter.fillPath(panel, self.palette().alternateBase().color())
|
||||
painter.setPen(QPen(self.palette().mid().color(), 1))
|
||||
painter.drawPath(panel)
|
||||
painter.setClipPath(panel) # keep bars inside the rounded shape
|
||||
inset = 3 # bar field sits 3px in from every edge
|
||||
avail_w = self.width() - 2 * inset
|
||||
avail_h = self.height() - 2 * inset
|
||||
baseline = self.height() - inset
|
||||
bar_width = avail_w / BANDS
|
||||
color = self._bar_color()
|
||||
step_height = avail_h / STEPS
|
||||
for i, bar in enumerate(self._bars):
|
||||
if bar <= 0:
|
||||
continue
|
||||
x = inset + int(i * bar_width)
|
||||
end = inset + int((i + 1) * bar_width)
|
||||
gap = 1 if i < BANDS - 1 else 0 # 1px between bars, none at edge
|
||||
w = max(1, end - x - gap)
|
||||
bar_height = int(bar * step_height)
|
||||
painter.fillRect(x, baseline - bar_height, w, bar_height, color)
|
||||
@@ -0,0 +1,106 @@
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from lintunes.models import Track
|
||||
from lintunes import tagging
|
||||
|
||||
|
||||
AUDIO_EXTENSIONS = {".mp3", ".m4a", ".flac", ".aac", ".wav", ".aiff", ".aif"}
|
||||
|
||||
_UNSAFE_CHARS = re.compile(r'[/\\:*?"<>|\x00-\x1f]')
|
||||
|
||||
|
||||
def is_audio_file(path: Path) -> bool:
|
||||
return path.suffix.lower() in AUDIO_EXTENSIONS
|
||||
|
||||
|
||||
def sanitize_component(name: str) -> str:
|
||||
name = _UNSAFE_CHARS.sub("_", name).strip().rstrip(".")
|
||||
return name or "Unknown"
|
||||
|
||||
|
||||
def organized_destination(music_dir: Path, fields: dict, filename: str) -> Path:
|
||||
"""<music_dir>/<Artist>/<Album>/<filename>, like iTunes' folder layout."""
|
||||
artist = sanitize_component(fields.get("album_artist") or fields.get("artist") or "Unknown Artist")
|
||||
album = sanitize_component(fields.get("album") or "Unknown Album")
|
||||
return music_dir / artist / album / sanitize_component(filename)
|
||||
|
||||
|
||||
def unique_path(dest: Path) -> Path:
|
||||
if not dest.exists():
|
||||
return dest
|
||||
for i in range(1, 1000):
|
||||
candidate = dest.with_stem(f"{dest.stem} {i}")
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
raise FileExistsError(f"Could not find a free name for {dest}")
|
||||
|
||||
|
||||
def import_file(source: Path, music_dir: Path, manager) -> Track | None:
|
||||
"""Copy an external audio file into the organized music dir and add it
|
||||
to the library. Returns the new Track, or the existing one if this exact
|
||||
file is already in the library."""
|
||||
source = Path(source).resolve()
|
||||
if not source.is_file() or not is_audio_file(source):
|
||||
return None
|
||||
|
||||
fields = tagging.read_tags(source)
|
||||
|
||||
# Already in the library? (same target path, or source is inside music_dir)
|
||||
for track in manager.library.tracks.values():
|
||||
if track.location and Path(track.location) == source:
|
||||
return track
|
||||
|
||||
if music_dir in source.parents:
|
||||
dest = source # already organized inside the music dir; don't copy
|
||||
else:
|
||||
dest = organized_destination(music_dir, fields, source.name)
|
||||
for track in manager.library.tracks.values():
|
||||
if track.location and Path(track.location) == dest:
|
||||
return track
|
||||
dest = unique_path(dest)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, dest)
|
||||
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
|
||||
track = Track(
|
||||
name=fields.get("name") or source.stem,
|
||||
location=str(dest),
|
||||
date_added=now,
|
||||
date_modified=now,
|
||||
)
|
||||
for key in ("artist", "album_artist", "album", "genre", "composer",
|
||||
"year", "track_number", "track_count", "disc_number", "disc_count",
|
||||
"comments", "total_time", "bit_rate", "sample_rate", "size", "kind"):
|
||||
if key in fields:
|
||||
setattr(track, key, fields[key])
|
||||
manager.add_track(track)
|
||||
return track
|
||||
|
||||
|
||||
def import_paths(paths: list[Path], music_dir: Path, manager,
|
||||
playlist_pid: str = "") -> list[Track]:
|
||||
"""Import files and directories (recursively); optionally add to a playlist."""
|
||||
files: list[Path] = []
|
||||
for path in paths:
|
||||
path = Path(path)
|
||||
if path.is_dir():
|
||||
files.extend(sorted(p for p in path.rglob("*") if p.is_file() and is_audio_file(p)))
|
||||
elif path.is_file() and is_audio_file(path):
|
||||
files.append(path)
|
||||
|
||||
imported = []
|
||||
for file_path in files:
|
||||
try:
|
||||
track = import_file(file_path, music_dir, manager)
|
||||
except Exception as e:
|
||||
print(f"Failed to import {file_path}: {e}")
|
||||
continue
|
||||
if track:
|
||||
imported.append(track)
|
||||
|
||||
if playlist_pid and imported:
|
||||
manager.add_tracks_to_playlist(playlist_pid, [t.track_id for t in imported])
|
||||
return imported
|
||||
@@ -0,0 +1,174 @@
|
||||
import os
|
||||
import plistlib
|
||||
import unicodedata
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from lintunes.models import Track, Playlist, PlaylistType, Library
|
||||
from lintunes.models.track import decode_location_url
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportReport:
|
||||
track_count: int = 0
|
||||
missing_files: list[str] = field(default_factory=list)
|
||||
unmapped_locations: list[str] = field(default_factory=list)
|
||||
skipped_tracks: int = 0 # non-file tracks (streams, remote)
|
||||
skipped_video: int = 0 # movies / TV shows / home videos
|
||||
case_fixed: int = 0 # locations fixed by case/normalization matching
|
||||
playlist_count: int = 0
|
||||
folder_count: int = 0
|
||||
skipped_smart: int = 0
|
||||
skipped_system: int = 0
|
||||
|
||||
def summary(self) -> str:
|
||||
lines = [
|
||||
f"Tracks imported: {self.track_count}",
|
||||
f"Tracks skipped: {self.skipped_tracks} (streams/remote), "
|
||||
f"{self.skipped_video} (video)",
|
||||
f"Locations adjusted: {self.case_fixed} (case/unicode normalization)",
|
||||
f"Missing files: {len(self.missing_files)}",
|
||||
f"Unmapped locations: {len(self.unmapped_locations)}",
|
||||
f"Playlists imported: {self.playlist_count} (+ {self.folder_count} folders)",
|
||||
f"Playlists skipped: {self.skipped_smart} smart, {self.skipped_system} system",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def make_location_remapper(music_folder_url: str, music_root: Path):
|
||||
"""Map decoded iTunes locations under the XML's Music Folder onto music_root.
|
||||
|
||||
music_folder_url is the raw 'Music Folder' value from the XML, e.g.
|
||||
file:///Volumes/Lucia/iTunes/iTunes%20Media/ — paths under it are
|
||||
rewritten to live under music_root. Paths outside it are returned
|
||||
unchanged (and end up in the unmapped report).
|
||||
"""
|
||||
prefix = decode_location_url(music_folder_url).rstrip("/")
|
||||
root = str(music_root).rstrip("/")
|
||||
|
||||
def remap(location: str) -> str:
|
||||
if prefix and location.startswith(prefix + "/"):
|
||||
return root + location[len(prefix):]
|
||||
return location
|
||||
|
||||
return remap
|
||||
|
||||
|
||||
class FuzzyPathResolver:
|
||||
"""Resolve paths whose case or Unicode normalization differs on disk.
|
||||
|
||||
macOS filesystems are case-insensitive and store names NFD-decomposed;
|
||||
on Linux the iTunes XML's exact path often misses the real file. Directory
|
||||
listings are cached, keyed by the folded (NFC + casefold) child name.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._cache: dict[str, dict[str, str] | None] = {}
|
||||
|
||||
@staticmethod
|
||||
def _fold(name: str) -> str:
|
||||
return unicodedata.normalize("NFC", name).casefold()
|
||||
|
||||
def _listing(self, directory: str) -> dict[str, str] | None:
|
||||
if directory not in self._cache:
|
||||
try:
|
||||
self._cache[directory] = {
|
||||
self._fold(entry): entry for entry in os.listdir(directory)
|
||||
}
|
||||
except OSError:
|
||||
self._cache[directory] = None
|
||||
return self._cache[directory]
|
||||
|
||||
def resolve(self, path_str: str) -> str | None:
|
||||
"""Return the on-disk path matching path_str, or None."""
|
||||
if os.path.exists(path_str):
|
||||
return path_str
|
||||
parts = Path(path_str).parts
|
||||
if not parts:
|
||||
return None
|
||||
current = parts[0]
|
||||
for part in parts[1:]:
|
||||
candidate = os.path.join(current, part)
|
||||
if os.path.exists(candidate):
|
||||
current = candidate
|
||||
continue
|
||||
listing = self._listing(current)
|
||||
if listing is None:
|
||||
return None
|
||||
match = listing.get(self._fold(part))
|
||||
if match is None:
|
||||
return None
|
||||
current = os.path.join(current, match)
|
||||
return current
|
||||
|
||||
|
||||
def import_itunes_xml(xml_path: Path, music_root: Path,
|
||||
check_files: bool = True) -> tuple[Library, ImportReport]:
|
||||
with open(xml_path, "rb") as f:
|
||||
plist = plistlib.load(f)
|
||||
|
||||
report = ImportReport()
|
||||
music_folder_url = plist.get("Music Folder", "")
|
||||
remap = make_location_remapper(music_folder_url, music_root)
|
||||
music_root_str = str(music_root).rstrip("/")
|
||||
|
||||
# Import tracks (audio file tracks only — no streams, no video)
|
||||
tracks = {}
|
||||
resolver = FuzzyPathResolver()
|
||||
for track_id_str, itunes_track in plist.get("Tracks", {}).items():
|
||||
if itunes_track.get("Track Type", "File") != "File":
|
||||
report.skipped_tracks += 1
|
||||
continue
|
||||
if (itunes_track.get("Movie") or itunes_track.get("TV Show")
|
||||
or itunes_track.get("Has Video")):
|
||||
report.skipped_video += 1
|
||||
continue
|
||||
track = Track.from_itunes_dict(itunes_track, remap_location=remap)
|
||||
if not track.location:
|
||||
report.skipped_tracks += 1
|
||||
continue
|
||||
if not track.location.startswith(music_root_str + "/"):
|
||||
report.unmapped_locations.append(track.location)
|
||||
elif check_files and not Path(track.location).exists():
|
||||
resolved = resolver.resolve(track.location)
|
||||
if resolved is not None:
|
||||
track.location = resolved
|
||||
report.case_fixed += 1
|
||||
else:
|
||||
report.missing_files.append(track.location)
|
||||
tracks[track.track_id] = track
|
||||
report.track_count = len(tracks)
|
||||
|
||||
# Import playlists: user playlists and folders only
|
||||
playlists = {}
|
||||
kept_folder_ids = set()
|
||||
for itunes_playlist in plist.get("Playlists", []):
|
||||
playlist = Playlist.from_itunes_dict(itunes_playlist)
|
||||
if playlist.is_system or playlist.playlist_type == PlaylistType.SYSTEM:
|
||||
report.skipped_system += 1
|
||||
continue
|
||||
if playlist.playlist_type == PlaylistType.SMART:
|
||||
report.skipped_smart += 1
|
||||
continue
|
||||
# Drop references to tracks we didn't import
|
||||
playlist.track_ids = [tid for tid in playlist.track_ids if tid in tracks]
|
||||
playlists[playlist.persistent_id] = playlist
|
||||
if playlist.playlist_type == PlaylistType.FOLDER:
|
||||
kept_folder_ids.add(playlist.persistent_id)
|
||||
|
||||
# Detach children whose parent folder wasn't imported
|
||||
for playlist in playlists.values():
|
||||
if playlist.parent_persistent_id and playlist.parent_persistent_id not in kept_folder_ids:
|
||||
playlist.parent_persistent_id = ""
|
||||
|
||||
report.folder_count = len(kept_folder_ids)
|
||||
report.playlist_count = len(playlists) - report.folder_count
|
||||
|
||||
library = Library(
|
||||
tracks=tracks,
|
||||
playlists=playlists,
|
||||
music_folder=str(music_root),
|
||||
import_date=datetime.now().isoformat(),
|
||||
)
|
||||
return library, report
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Keep the machine awake while music is playing.
|
||||
|
||||
Uses the GNOME SessionManager D-Bus interface (the same session bus PyQt6's
|
||||
QtDBus already talks to for MPRIS) to register a *suspend* inhibitor. GNOME's
|
||||
critical-battery action overrides this inhibitor, so a near-dead laptop still
|
||||
suspends as expected — we only block idle/automatic suspend while playing.
|
||||
|
||||
On non-GNOME desktops the interface won't be available; every method then
|
||||
no-ops silently rather than failing.
|
||||
"""
|
||||
from PyQt6.QtDBus import QDBusConnection, QDBusInterface, QDBusReply
|
||||
|
||||
|
||||
SERVICE = "org.gnome.SessionManager"
|
||||
PATH = "/org/gnome/SessionManager"
|
||||
INHIBIT_SUSPEND = 4 # GsmInhibitorFlag: inhibit suspending the session/computer
|
||||
APP_ID = "org.lintunes.LinTunes"
|
||||
REASON = "Playing music"
|
||||
|
||||
|
||||
class SleepInhibitor:
|
||||
def __init__(self):
|
||||
self._cookie: int | None = None
|
||||
bus = QDBusConnection.sessionBus()
|
||||
self._iface = QDBusInterface(SERVICE, PATH, SERVICE, bus)
|
||||
|
||||
def _available(self) -> bool:
|
||||
return self._iface.isValid()
|
||||
|
||||
def inhibit(self):
|
||||
"""Block automatic suspend. No-op if already inhibiting or unavailable."""
|
||||
if self._cookie is not None or not self._available():
|
||||
return
|
||||
reply = QDBusReply(self._iface.call(
|
||||
"Inhibit", APP_ID, 0, REASON, INHIBIT_SUSPEND))
|
||||
if reply.isValid():
|
||||
self._cookie = int(reply.value())
|
||||
|
||||
def release(self):
|
||||
"""Release a held inhibitor, if any."""
|
||||
if self._cookie is None:
|
||||
return
|
||||
if self._available():
|
||||
self._iface.call("Uninhibit", self._cookie)
|
||||
self._cookie = None
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Decode iTunes `.itc` album-artwork cache files.
|
||||
|
||||
`.itc` files (the unencrypted kind iTunes 12 writes under
|
||||
`iTunes/Album Artwork/{Cache,Download,Custom,...}/`) are a tiny atom container:
|
||||
|
||||
[4-byte big-endian frame size]["itch"][...]["artw"]["item"]...<image bytes>
|
||||
|
||||
The header carries no track/album identifier (that mapping lives only in the
|
||||
binary `iTunes Library.itl`); the link to a track is the *filename* token. The
|
||||
payload is a plain JPEG or PNG for the vast majority of entries. A minority use
|
||||
Apple's raw `ARGb`/`PNGf` pixel formats with no magic bytes — those we can't
|
||||
recover here and report as unsupported.
|
||||
|
||||
This module is intentionally dependency-free (no Pillow) so it can run as part of
|
||||
a migration script and be unit-tested with a synthesized blob.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
_JPEG_SOI = b"\xff\xd8\xff"
|
||||
_JPEG_EOI = b"\xff\xd9"
|
||||
_PNG_SIG = b"\x89PNG\r\n\x1a\n"
|
||||
_PNG_IEND = b"IEND\xae\x42\x60\x82"
|
||||
|
||||
|
||||
def decode_itc(data: bytes) -> tuple[bytes, str] | None:
|
||||
"""Extract the embedded image from `.itc` bytes.
|
||||
|
||||
Returns ``(image_bytes, mime)`` for JPEG/PNG payloads, or ``None`` when the
|
||||
payload is a raw Apple bitmap (no JPEG/PNG magic) that we can't recover.
|
||||
"""
|
||||
soi = data.find(_JPEG_SOI)
|
||||
png = data.find(_PNG_SIG)
|
||||
|
||||
# Prefer whichever magic appears first (a header field name could in theory
|
||||
# collide, but the real image is always the earliest magic in practice).
|
||||
candidates = [(soi, "jpeg"), (png, "png")]
|
||||
candidates = [(pos, kind) for pos, kind in candidates if pos != -1]
|
||||
if not candidates:
|
||||
return None
|
||||
pos, kind = min(candidates)
|
||||
|
||||
if kind == "jpeg":
|
||||
end = data.rfind(_JPEG_EOI)
|
||||
image = data[pos:end + 2] if end > pos else data[pos:]
|
||||
return image, "image/jpeg"
|
||||
# png
|
||||
end = data.find(_PNG_IEND, pos)
|
||||
image = data[pos:end + len(_PNG_IEND)] if end != -1 else data[pos:]
|
||||
return image, "image/png"
|
||||
|
||||
|
||||
def decode_itc_file(path: str | Path) -> tuple[bytes, str] | None:
|
||||
"""Read an `.itc` file and decode its image (see :func:`decode_itc`)."""
|
||||
return decode_itc(Path(path).read_bytes())
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Last.fm scrobbling over the plain web API (no extra deps beyond requests).
|
||||
|
||||
Auth: auth.getMobileSession with username/password + the user's own API
|
||||
key/secret (register at https://www.last.fm/api/account/create). Only the
|
||||
returned session key is persisted, never the password.
|
||||
|
||||
Scrobbles happen when a track plays to the very end (the player's
|
||||
track_finished signal). Failures are queued to <data_dir>/scrobble_queue.json
|
||||
and retried on startup and after the next successful call. All network I/O
|
||||
runs on background threads.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
|
||||
|
||||
API_URL = "https://ws.audioscrobbler.com/2.0/"
|
||||
QUEUE_FILE = "scrobble_queue.json"
|
||||
QUEUE_LIMIT = 500
|
||||
|
||||
|
||||
def sign(params: dict, secret: str) -> str:
|
||||
"""Last.fm api_sig: md5 of concatenated key+value pairs (sorted by key,
|
||||
excluding 'format'/'callback') followed by the shared secret."""
|
||||
pieces = [f"{k}{params[k]}" for k in sorted(params)
|
||||
if k not in ("format", "callback")]
|
||||
raw = "".join(pieces) + secret
|
||||
return hashlib.md5(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class LastFm(QObject):
|
||||
login_finished = pyqtSignal(bool, str) # success, message
|
||||
status_message = pyqtSignal(str)
|
||||
|
||||
def __init__(self, prefs, data_dir: Path, parent=None):
|
||||
super().__init__(parent)
|
||||
self._prefs = prefs
|
||||
self._queue_path = Path(data_dir) / QUEUE_FILE
|
||||
# Retry anything left over from previous runs
|
||||
if self.is_logged_in() and self._load_queue():
|
||||
self._spawn(self._flush_queue)
|
||||
|
||||
# ---- state ----
|
||||
|
||||
def is_logged_in(self) -> bool:
|
||||
lf = self._prefs.lastfm
|
||||
return bool(lf.get("session_key") and lf.get("api_key")
|
||||
and lf.get("api_secret"))
|
||||
|
||||
def scrobbling_enabled(self) -> bool:
|
||||
return self.is_logged_in() and self._prefs.lastfm.get("scrobble_enabled")
|
||||
|
||||
# ---- auth ----
|
||||
|
||||
def login(self, api_key: str, api_secret: str, username: str, password: str):
|
||||
def work():
|
||||
try:
|
||||
data = self._call(api_key, api_secret, "auth.getMobileSession",
|
||||
{"username": username, "password": password},
|
||||
session_key=None)
|
||||
session = data["session"]
|
||||
self._prefs.update_lastfm(
|
||||
api_key=api_key, api_secret=api_secret,
|
||||
username=session.get("name", username),
|
||||
session_key=session["key"])
|
||||
self.login_finished.emit(
|
||||
True, f"Logged in as {session.get('name', username)}")
|
||||
except Exception as e:
|
||||
self.login_finished.emit(False, f"Login failed: {e}")
|
||||
self._spawn(work)
|
||||
|
||||
def logout(self):
|
||||
self._prefs.update_lastfm(session_key="", scrobble_enabled=False)
|
||||
|
||||
# ---- playback hooks ----
|
||||
|
||||
def handle_track_started(self, track):
|
||||
if track is None or not self.scrobbling_enabled() or not track.artist:
|
||||
return
|
||||
params = {"artist": track.artist, "track": track.name}
|
||||
if track.album:
|
||||
params["album"] = track.album
|
||||
if track.total_time:
|
||||
params["duration"] = str(track.total_time // 1000)
|
||||
self._spawn(lambda: self._try_call("track.updateNowPlaying", params))
|
||||
|
||||
def handle_track_finished(self, track):
|
||||
if track is None or not self.scrobbling_enabled() or not track.artist:
|
||||
return
|
||||
duration_s = (track.total_time or 0) // 1000
|
||||
entry = {
|
||||
"artist": track.artist,
|
||||
"track": track.name,
|
||||
"album": track.album or "",
|
||||
"timestamp": int(time.time()) - duration_s,
|
||||
"duration": duration_s,
|
||||
}
|
||||
self._spawn(lambda: self._scrobble_entry(entry))
|
||||
|
||||
# ---- internals ----
|
||||
|
||||
def _scrobble_entry(self, entry: dict):
|
||||
params = {"artist": entry["artist"], "track": entry["track"],
|
||||
"timestamp": str(entry["timestamp"])}
|
||||
if entry.get("album"):
|
||||
params["album"] = entry["album"]
|
||||
if entry.get("duration"):
|
||||
params["duration"] = str(entry["duration"])
|
||||
try:
|
||||
self._call_with_session("track.scrobble", params)
|
||||
self.status_message.emit(
|
||||
f"Scrobbled: {entry['artist']} — {entry['track']}")
|
||||
self._flush_queue()
|
||||
except Exception as e:
|
||||
self._enqueue(entry)
|
||||
self.status_message.emit(f"Scrobble queued (offline?): {e}")
|
||||
|
||||
def _flush_queue(self):
|
||||
queue = self._load_queue()
|
||||
if not queue:
|
||||
return
|
||||
remaining = []
|
||||
for entry in queue:
|
||||
try:
|
||||
params = {"artist": entry["artist"], "track": entry["track"],
|
||||
"timestamp": str(entry["timestamp"])}
|
||||
if entry.get("album"):
|
||||
params["album"] = entry["album"]
|
||||
self._call_with_session("track.scrobble", params)
|
||||
except Exception:
|
||||
remaining.append(entry)
|
||||
self._save_queue(remaining)
|
||||
flushed = len(queue) - len(remaining)
|
||||
if flushed:
|
||||
self.status_message.emit(f"Sent {flushed} queued scrobble(s)")
|
||||
|
||||
def _try_call(self, method: str, params: dict):
|
||||
try:
|
||||
self._call_with_session(method, params)
|
||||
except Exception:
|
||||
pass # now-playing updates are best-effort
|
||||
|
||||
def _call_with_session(self, method: str, params: dict) -> dict:
|
||||
lf = self._prefs.lastfm
|
||||
return self._call(lf["api_key"], lf["api_secret"], method, params,
|
||||
session_key=lf["session_key"])
|
||||
|
||||
@staticmethod
|
||||
def _call(api_key: str, secret: str, method: str, params: dict,
|
||||
session_key: str | None) -> dict:
|
||||
import requests
|
||||
payload = dict(params)
|
||||
payload["method"] = method
|
||||
payload["api_key"] = api_key
|
||||
if session_key:
|
||||
payload["sk"] = session_key
|
||||
payload["api_sig"] = sign(payload, secret)
|
||||
payload["format"] = "json"
|
||||
response = requests.post(API_URL, data=payload, timeout=15)
|
||||
data = response.json()
|
||||
if "error" in data:
|
||||
raise RuntimeError(data.get("message", f"error {data['error']}"))
|
||||
response.raise_for_status()
|
||||
return data
|
||||
|
||||
# ---- offline queue ----
|
||||
|
||||
def _enqueue(self, entry: dict):
|
||||
queue = self._load_queue()
|
||||
queue.append(entry)
|
||||
self._save_queue(queue[-QUEUE_LIMIT:])
|
||||
|
||||
def _load_queue(self) -> list:
|
||||
if not self._queue_path.exists():
|
||||
return []
|
||||
try:
|
||||
with open(self._queue_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (ValueError, OSError):
|
||||
return []
|
||||
|
||||
def _save_queue(self, queue: list):
|
||||
if not queue:
|
||||
if self._queue_path.exists():
|
||||
self._queue_path.unlink()
|
||||
return
|
||||
self._queue_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self._queue_path.with_suffix(".json.tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(queue, f, indent=2)
|
||||
tmp.replace(self._queue_path)
|
||||
|
||||
@staticmethod
|
||||
def _spawn(fn):
|
||||
threading.Thread(target=fn, daemon=True).start()
|
||||
@@ -0,0 +1,434 @@
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QObject, QTimer, pyqtSignal
|
||||
|
||||
from lintunes import tagging
|
||||
from lintunes.models import Library, Playlist, PlaylistType
|
||||
from lintunes.storage import json_storage
|
||||
from lintunes.undo import Command, UndoStack
|
||||
|
||||
|
||||
SAVE_DEBOUNCE_MS = 3000
|
||||
|
||||
|
||||
class LibraryManager(QObject):
|
||||
"""Owns the Library, applies mutations, and persists them (debounced).
|
||||
|
||||
Dirty state is tracked per area so a play-count bump only rewrites
|
||||
library.json and a playlist edit only rewrites that playlist's file.
|
||||
|
||||
User-driven structural edits (playlist create/delete/rename/move, track
|
||||
add/remove/reorder, metadata edits) are recorded on ``undo_stack`` so they
|
||||
can be reversed with Ctrl+Z. The internal ``_apply_*``/``_set_*`` helpers do
|
||||
the actual mutation (plus dirty-marking and the existing refresh signals)
|
||||
and never touch the stack, so undo/redo reuse them without recursing.
|
||||
"""
|
||||
|
||||
playlists_changed = pyqtSignal() # structure: add/remove/rename/move
|
||||
playlist_content_changed = pyqtSignal(str) # persistent_id
|
||||
track_updated = pyqtSignal(int) # track_id
|
||||
track_fields_edited = pyqtSignal(int, list) # track_id, changed field names
|
||||
|
||||
def __init__(self, library: Library, data_dir: Path, parent=None):
|
||||
super().__init__(parent)
|
||||
self.library = library
|
||||
self.data_dir = Path(data_dir)
|
||||
self.undo_stack = UndoStack(parent=self)
|
||||
|
||||
self._dirty_tracks = False
|
||||
self._dirty_metadata = False
|
||||
self._dirty_playlists: set[str] = set()
|
||||
self._deleted_playlists: set[str] = set()
|
||||
|
||||
self._save_timer = QTimer(self)
|
||||
self._save_timer.setSingleShot(True)
|
||||
self._save_timer.setInterval(SAVE_DEBOUNCE_MS)
|
||||
self._save_timer.timeout.connect(self.flush)
|
||||
|
||||
# ---- id generation ----
|
||||
|
||||
def new_track_id(self) -> int:
|
||||
return max(self.library.tracks.keys(), default=0) + 1
|
||||
|
||||
def new_persistent_id(self) -> str:
|
||||
while True:
|
||||
pid = secrets.token_hex(8).upper()
|
||||
if pid not in self.library.playlists:
|
||||
return pid
|
||||
|
||||
# ---- playlist structure ----
|
||||
|
||||
def create_playlist(self, name: str, parent_pid: str = "") -> Playlist:
|
||||
playlist = Playlist(
|
||||
name=name,
|
||||
persistent_id=self.new_persistent_id(),
|
||||
parent_persistent_id=parent_pid,
|
||||
playlist_type=PlaylistType.REGULAR,
|
||||
)
|
||||
self._insert_playlists([playlist])
|
||||
self.undo_stack.push(Command(
|
||||
"New Playlist",
|
||||
undo=lambda: self._remove_playlists([playlist]),
|
||||
redo=lambda: self._insert_playlists([playlist])))
|
||||
return playlist
|
||||
|
||||
def create_folder(self, name: str, parent_pid: str = "") -> Playlist:
|
||||
folder = Playlist(
|
||||
name=name,
|
||||
persistent_id=self.new_persistent_id(),
|
||||
parent_persistent_id=parent_pid,
|
||||
playlist_type=PlaylistType.FOLDER,
|
||||
)
|
||||
self._insert_playlists([folder])
|
||||
self.undo_stack.push(Command(
|
||||
"New Folder",
|
||||
undo=lambda: self._remove_playlists([folder]),
|
||||
redo=lambda: self._insert_playlists([folder])))
|
||||
return folder
|
||||
|
||||
def rename_playlist(self, pid: str, name: str):
|
||||
playlist = self.library.playlists.get(pid)
|
||||
if playlist and name and playlist.name != name:
|
||||
old = playlist.name
|
||||
self._apply_rename(pid, name)
|
||||
self.undo_stack.push(Command(
|
||||
"Rename Playlist",
|
||||
undo=lambda: self._apply_rename(pid, old),
|
||||
redo=lambda: self._apply_rename(pid, name)))
|
||||
|
||||
def delete_playlist(self, pid: str):
|
||||
"""Delete a playlist, or a folder and everything inside it."""
|
||||
playlist = self.library.playlists.get(pid)
|
||||
if not playlist:
|
||||
return
|
||||
doomed_ids = [pid]
|
||||
if playlist.playlist_type == PlaylistType.FOLDER:
|
||||
doomed_ids.extend(self._descendant_ids(pid))
|
||||
doomed = [self.library.playlists[d] for d in doomed_ids
|
||||
if d in self.library.playlists]
|
||||
self._remove_playlists(doomed)
|
||||
self.undo_stack.push(Command(
|
||||
"Delete Playlist",
|
||||
undo=lambda: self._insert_playlists(doomed),
|
||||
redo=lambda: self._remove_playlists(doomed)))
|
||||
|
||||
def move_playlist(self, pid: str, new_parent_pid: str):
|
||||
playlist = self.library.playlists.get(pid)
|
||||
if not playlist or pid == new_parent_pid:
|
||||
return
|
||||
if new_parent_pid and new_parent_pid in self._descendant_ids(pid):
|
||||
return # no cycles
|
||||
old_parent = playlist.parent_persistent_id
|
||||
if old_parent == new_parent_pid:
|
||||
return
|
||||
self._apply_reparent(pid, new_parent_pid)
|
||||
self.undo_stack.push(Command(
|
||||
"Move Playlist",
|
||||
undo=lambda: self._apply_reparent(pid, old_parent),
|
||||
redo=lambda: self._apply_reparent(pid, new_parent_pid)))
|
||||
|
||||
def playlists_containing(self, track_id: int) -> list[tuple[str, str]]:
|
||||
"""(persistent_id, name) of every REGULAR playlist holding this track,
|
||||
sorted by name. Folders/smart/system playlists are excluded — they
|
||||
aren't jump-to targets with static membership."""
|
||||
out = [(p.persistent_id, p.name)
|
||||
for p in self.library.playlists.values()
|
||||
if p.playlist_type == PlaylistType.REGULAR
|
||||
and track_id in p.track_ids]
|
||||
return sorted(out, key=lambda pn: pn[1].casefold())
|
||||
|
||||
def _descendant_ids(self, pid: str) -> list[str]:
|
||||
result = []
|
||||
stack = [pid]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
for p in self.library.playlists.values():
|
||||
if p.parent_persistent_id == current:
|
||||
result.append(p.persistent_id)
|
||||
stack.append(p.persistent_id)
|
||||
return result
|
||||
|
||||
# structure apply helpers (no undo push) --------------------------------
|
||||
|
||||
def _insert_playlists(self, playlists: list[Playlist]):
|
||||
for playlist in playlists:
|
||||
self.library.playlists[playlist.persistent_id] = playlist
|
||||
self._dirty_playlists.add(playlist.persistent_id)
|
||||
self._deleted_playlists.discard(playlist.persistent_id)
|
||||
self._dirty_metadata = True
|
||||
self._schedule_save()
|
||||
self.playlists_changed.emit()
|
||||
|
||||
def _remove_playlists(self, playlists: list[Playlist]):
|
||||
for playlist in playlists:
|
||||
pid = playlist.persistent_id
|
||||
self.library.playlists.pop(pid, None)
|
||||
self._dirty_playlists.discard(pid)
|
||||
self._deleted_playlists.add(pid)
|
||||
self._dirty_metadata = True
|
||||
self._schedule_save()
|
||||
self.playlists_changed.emit()
|
||||
|
||||
def _apply_rename(self, pid: str, name: str):
|
||||
playlist = self.library.playlists.get(pid)
|
||||
if playlist:
|
||||
playlist.name = name
|
||||
self._mark_playlist(pid)
|
||||
self.playlists_changed.emit()
|
||||
|
||||
def _apply_reparent(self, pid: str, parent_pid: str):
|
||||
playlist = self.library.playlists.get(pid)
|
||||
if playlist:
|
||||
playlist.parent_persistent_id = parent_pid
|
||||
self._mark_playlist(pid)
|
||||
self.playlists_changed.emit()
|
||||
|
||||
# ---- playlist contents ----
|
||||
|
||||
def add_tracks_to_playlist(self, pid: str, track_ids: list[int],
|
||||
position: int | None = None):
|
||||
playlist = self.library.playlists.get(pid)
|
||||
if not playlist or playlist.playlist_type == PlaylistType.FOLDER:
|
||||
return
|
||||
track_ids = [tid for tid in track_ids if tid in self.library.tracks]
|
||||
if not track_ids:
|
||||
return
|
||||
before = list(playlist.track_ids)
|
||||
after = list(before)
|
||||
if position is None or position >= len(after):
|
||||
after.extend(track_ids)
|
||||
else:
|
||||
after[position:position] = track_ids
|
||||
self._set_track_ids(pid, after)
|
||||
self._push_track_ids("Add to Playlist", pid, before, after)
|
||||
|
||||
def remove_tracks_from_playlist(self, pid: str, rows: list[int]):
|
||||
playlist = self.library.playlists.get(pid)
|
||||
if not playlist:
|
||||
return
|
||||
before = list(playlist.track_ids)
|
||||
after = list(before)
|
||||
for row in sorted(set(rows), reverse=True):
|
||||
if 0 <= row < len(after):
|
||||
del after[row]
|
||||
self._set_track_ids(pid, after)
|
||||
self._push_track_ids("Remove from Playlist", pid, before, after)
|
||||
|
||||
def move_tracks_in_playlist(self, pid: str, rows: list[int], dest: int):
|
||||
"""Move the tracks at the given manual-order rows so the block starts at dest."""
|
||||
playlist = self.library.playlists.get(pid)
|
||||
if not playlist:
|
||||
return
|
||||
before = list(playlist.track_ids)
|
||||
rows = sorted(set(r for r in rows if 0 <= r < len(before)))
|
||||
if not rows:
|
||||
return
|
||||
moving = [before[r] for r in rows]
|
||||
# Destination index counted in the list *after* removal
|
||||
dest -= sum(1 for r in rows if r < dest)
|
||||
remaining = [tid for i, tid in enumerate(before) if i not in rows]
|
||||
dest = max(0, min(dest, len(remaining)))
|
||||
after = remaining[:dest] + moving + remaining[dest:]
|
||||
self._set_track_ids(pid, after)
|
||||
self._push_track_ids("Reorder Playlist", pid, before, after)
|
||||
|
||||
def _set_track_ids(self, pid: str, ids: list[int]):
|
||||
playlist = self.library.playlists.get(pid)
|
||||
if playlist is None:
|
||||
return
|
||||
playlist.track_ids = list(ids)
|
||||
self._mark_playlist(pid)
|
||||
self.playlist_content_changed.emit(pid)
|
||||
|
||||
def _push_track_ids(self, label, pid, before, after):
|
||||
if before != after:
|
||||
self.undo_stack.push(Command(
|
||||
label,
|
||||
undo=lambda: self._set_track_ids(pid, before),
|
||||
redo=lambda: self._set_track_ids(pid, after)))
|
||||
|
||||
# ---- tracks ----
|
||||
|
||||
def add_track(self, track) -> int:
|
||||
if not track.track_id or track.track_id in self.library.tracks:
|
||||
track.track_id = self.new_track_id()
|
||||
if not track.persistent_id:
|
||||
track.persistent_id = secrets.token_hex(8).upper()
|
||||
self.library.tracks[track.track_id] = track
|
||||
self._dirty_tracks = True
|
||||
self._dirty_metadata = True
|
||||
self._schedule_save()
|
||||
return track.track_id
|
||||
|
||||
def update_track_fields(self, track_id: int, fields: dict):
|
||||
"""In-memory-only field update (no file write, not undoable)."""
|
||||
track = self.library.tracks.get(track_id)
|
||||
if not track:
|
||||
return
|
||||
changed_keys = []
|
||||
for key, value in fields.items():
|
||||
if hasattr(track, key) and getattr(track, key) != value:
|
||||
setattr(track, key, value)
|
||||
changed_keys.append(key)
|
||||
if changed_keys:
|
||||
track.date_modified = _utc_now_iso()
|
||||
self._dirty_tracks = True
|
||||
self._schedule_save()
|
||||
self.track_updated.emit(track_id)
|
||||
self.track_fields_edited.emit(track_id, changed_keys)
|
||||
|
||||
def set_track_location(self, track_id: int, location: str):
|
||||
"""Repoint a track at a new on-disk path (e.g. user relocated a file
|
||||
that was moved/renamed outside lintunes). Not a content edit: no tag
|
||||
write, no date_modified bump, not undoable."""
|
||||
track = self.library.tracks.get(track_id)
|
||||
if not track or track.location == location:
|
||||
return
|
||||
track.location = location
|
||||
self._dirty_tracks = True
|
||||
self._schedule_save()
|
||||
self.track_updated.emit(track_id)
|
||||
|
||||
def edit_track_fields(self, track_id: int, fields: dict):
|
||||
"""Edit a track's metadata: write the changed tags to the audio file,
|
||||
update the in-memory library, and record the change for undo.
|
||||
|
||||
Reverts text/number fields only — artwork is written separately by the
|
||||
Info dialog and is not part of the undo history.
|
||||
"""
|
||||
track = self.library.tracks.get(track_id)
|
||||
if not track:
|
||||
return
|
||||
changed = {k: v for k, v in fields.items()
|
||||
if hasattr(track, k) and getattr(track, k) != v}
|
||||
if not changed:
|
||||
return
|
||||
old = {k: getattr(track, k) for k in changed}
|
||||
if not self._write_track_tags(track, changed):
|
||||
# File write failed: leave the library untouched so memory and file
|
||||
# don't diverge, and don't record an un-undoable phantom edit.
|
||||
return
|
||||
self._apply_track_fields(track_id, changed)
|
||||
self.undo_stack.push(Command(
|
||||
"Edit Info",
|
||||
undo=lambda: self._revert_track_fields(track_id, old),
|
||||
redo=lambda: self._revert_track_fields(track_id, changed)))
|
||||
|
||||
def edit_tracks_fields(self, track_ids: list[int], fields: dict):
|
||||
"""Apply the same field edits to many tracks at once, writing each
|
||||
file's tags, recorded as ONE undoable command.
|
||||
|
||||
Per track only fields that genuinely differ are written, so a value a
|
||||
track already has costs nothing and a track that ends up unchanged is
|
||||
skipped entirely (no file write, no undo entry).
|
||||
"""
|
||||
changes = [] # (track_id, new_fields, old_fields)
|
||||
for track_id in track_ids:
|
||||
track = self.library.tracks.get(track_id)
|
||||
if not track:
|
||||
continue
|
||||
changed = {k: v for k, v in fields.items()
|
||||
if hasattr(track, k) and getattr(track, k) != v}
|
||||
if not changed or not self._write_track_tags(track, changed):
|
||||
continue
|
||||
old = {k: getattr(track, k) for k in changed}
|
||||
self._apply_track_fields(track_id, changed)
|
||||
changes.append((track_id, changed, old))
|
||||
if not changes:
|
||||
return
|
||||
self.undo_stack.push(Command(
|
||||
"Edit Info",
|
||||
undo=lambda: [self._revert_track_fields(tid, old)
|
||||
for tid, _new, old in changes],
|
||||
redo=lambda: [self._revert_track_fields(tid, new)
|
||||
for tid, new, _old in changes]))
|
||||
|
||||
def _write_track_tags(self, track, field_map: dict) -> bool:
|
||||
if not track.location:
|
||||
return True
|
||||
try:
|
||||
tagging.write_tags(track.location, field_map)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Could not write tags to {track.location}: {e}")
|
||||
return False
|
||||
|
||||
def _apply_track_fields(self, track_id: int, field_map: dict):
|
||||
track = self.library.tracks.get(track_id)
|
||||
if not track:
|
||||
return
|
||||
for key, value in field_map.items():
|
||||
setattr(track, key, value)
|
||||
track.date_modified = _utc_now_iso()
|
||||
self._dirty_tracks = True
|
||||
self._schedule_save()
|
||||
self.track_updated.emit(track_id)
|
||||
self.track_fields_edited.emit(track_id, list(field_map.keys()))
|
||||
|
||||
def _revert_track_fields(self, track_id: int, field_map: dict):
|
||||
track = self.library.tracks.get(track_id)
|
||||
if track:
|
||||
self._write_track_tags(track, field_map) # best-effort
|
||||
self._apply_track_fields(track_id, field_map)
|
||||
|
||||
def record_play(self, track_id: int):
|
||||
track = self.library.tracks.get(track_id)
|
||||
if not track:
|
||||
return
|
||||
track.play_count += 1
|
||||
track.play_date_utc = _utc_now_iso()
|
||||
self._dirty_tracks = True
|
||||
self._schedule_save()
|
||||
self.track_updated.emit(track_id)
|
||||
|
||||
def record_skip(self, track_id: int):
|
||||
track = self.library.tracks.get(track_id)
|
||||
if not track:
|
||||
return
|
||||
track.skip_count += 1
|
||||
track.skip_date = _utc_now_iso()
|
||||
self._dirty_tracks = True
|
||||
self._schedule_save()
|
||||
self.track_updated.emit(track_id)
|
||||
|
||||
# ---- settings persistence (no signals; UI-originated) ----
|
||||
|
||||
def mark_playlist_settings_dirty(self, pid: str):
|
||||
self._mark_playlist(pid)
|
||||
|
||||
def mark_library_settings_dirty(self):
|
||||
self._dirty_metadata = True
|
||||
self._schedule_save()
|
||||
|
||||
# ---- saving ----
|
||||
|
||||
def _mark_playlist(self, pid: str):
|
||||
self._dirty_playlists.add(pid)
|
||||
self._dirty_metadata = True
|
||||
self._schedule_save()
|
||||
|
||||
def _schedule_save(self):
|
||||
self._save_timer.start()
|
||||
|
||||
def flush(self):
|
||||
self._save_timer.stop()
|
||||
if self._dirty_tracks:
|
||||
json_storage.save_tracks(self.library, self.data_dir)
|
||||
self._dirty_tracks = False
|
||||
for pid in list(self._dirty_playlists):
|
||||
playlist = self.library.playlists.get(pid)
|
||||
if playlist:
|
||||
json_storage.save_playlist(playlist, self.data_dir)
|
||||
self._dirty_playlists.clear()
|
||||
for pid in list(self._deleted_playlists):
|
||||
json_storage.delete_playlist_file(pid, self.data_dir)
|
||||
self._deleted_playlists.clear()
|
||||
if self._dirty_metadata:
|
||||
json_storage.save_metadata(self.library, self.data_dir)
|
||||
self._dirty_metadata = False
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
|
||||
@@ -0,0 +1,173 @@
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from lintunes.config import load_config, save_config
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="lintunes",
|
||||
description="LinTunes — iTunes library manager for Linux",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--import-xml",
|
||||
dest="import_file",
|
||||
metavar="XML_FILE",
|
||||
help="Import an iTunes Library XML file and exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--music-root",
|
||||
help="Local directory corresponding to iTunes' Music Folder "
|
||||
"(the 'iTunes Media' directory)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--data-dir",
|
||||
help="Directory holding the lintunes library JSON files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--save-config",
|
||||
action="store_true",
|
||||
help="Persist --music-root/--data-dir as defaults",
|
||||
)
|
||||
parser.add_argument(
|
||||
"files",
|
||||
nargs="*",
|
||||
help="Audio files to import into the library on startup",
|
||||
)
|
||||
|
||||
# Let Qt flags like `-platform xcb` pass through to QApplication
|
||||
args, qt_args = parser.parse_known_args()
|
||||
config = load_config()
|
||||
music_root = args.music_root or config.get("music_root")
|
||||
data_dir = args.data_dir or config.get("data_dir")
|
||||
|
||||
if args.save_config and (args.music_root or args.data_dir):
|
||||
config.update({k: v for k, v in
|
||||
(("music_root", args.music_root), ("data_dir", args.data_dir))
|
||||
if v})
|
||||
save_config(config)
|
||||
print(f"Saved config: {config}")
|
||||
|
||||
if not data_dir:
|
||||
print("Error: no data directory configured.\n"
|
||||
"Run with --data-dir DIR (add --save-config to remember it).")
|
||||
sys.exit(1)
|
||||
data_dir = Path(data_dir)
|
||||
|
||||
if args.import_file:
|
||||
run_import(Path(args.import_file), music_root, data_dir)
|
||||
else:
|
||||
run_gui(data_dir, [Path(f) for f in args.files], qt_args)
|
||||
|
||||
|
||||
def run_import(xml_path: Path, music_root: str | None, data_dir: Path):
|
||||
from lintunes.importers.itunes_importer import import_itunes_xml
|
||||
from lintunes.storage.json_storage import save_library
|
||||
|
||||
if not xml_path.exists():
|
||||
print(f"Error: File not found: {xml_path}")
|
||||
sys.exit(1)
|
||||
if not music_root:
|
||||
print("Error: --music-root is required for import "
|
||||
"(the local path of iTunes' 'iTunes Media' folder)")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Importing from {xml_path}...")
|
||||
library, report = import_itunes_xml(xml_path, Path(music_root))
|
||||
print(report.summary())
|
||||
|
||||
if report.missing_files:
|
||||
missing_log = data_dir / "import_missing_files.txt"
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
missing_log.write_text("\n".join(report.missing_files), encoding="utf-8")
|
||||
print(f"Missing file list written to {missing_log}")
|
||||
if report.unmapped_locations:
|
||||
unmapped_log = data_dir / "import_unmapped_locations.txt"
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
unmapped_log.write_text("\n".join(report.unmapped_locations), encoding="utf-8")
|
||||
print(f"Unmapped location list written to {unmapped_log}")
|
||||
|
||||
print(f"Saving library to {data_dir}...")
|
||||
save_library(library, data_dir)
|
||||
print("Import complete!")
|
||||
|
||||
|
||||
def run_gui(data_dir: Path, files: list[Path], qt_args: list[str] | None = None):
|
||||
from PyQt6.QtWidgets import QApplication, QMessageBox, QFontDialog
|
||||
|
||||
from lintunes import theme
|
||||
from lintunes.storage.json_storage import load_library
|
||||
from lintunes.storage.conflict_resolver import resolve_conflicts
|
||||
from lintunes.library_manager import LibraryManager
|
||||
from lintunes.preferences import Preferences
|
||||
from lintunes.lastfm import LastFm
|
||||
from lintunes.gui.main_window import MainWindow
|
||||
from lintunes.mpris import setup_mpris
|
||||
|
||||
resolve_conflicts(data_dir)
|
||||
library = load_library(data_dir)
|
||||
|
||||
app = QApplication([sys.argv[0]] + (qt_args or []))
|
||||
app.setApplicationName("LinTunes")
|
||||
app.setDesktopFileName("lintunes")
|
||||
|
||||
prefs = Preferences(data_dir)
|
||||
theme.apply_theme(app, prefs)
|
||||
_ensure_now_playing_font(prefs)
|
||||
manager = LibraryManager(library, data_dir)
|
||||
lastfm = LastFm(prefs, data_dir)
|
||||
window = MainWindow(manager, prefs, lastfm)
|
||||
app.installEventFilter(window)
|
||||
mpris = setup_mpris(window.player, window) # noqa: F841 — keep alive
|
||||
app.aboutToQuit.connect(manager.flush)
|
||||
|
||||
window.show()
|
||||
if files:
|
||||
window.import_files(files)
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
def _ensure_now_playing_font(prefs):
|
||||
"""Prompt for a now-playing font only when needed, then persist the choice.
|
||||
|
||||
Stays silent when Century Gothic is installed (and the user hasn't picked a
|
||||
substitute), or when the user has already chosen "use default". Prompts
|
||||
when the desired family is missing, offering "Pick a font…" (QFontDialog)
|
||||
or "Use default". Canceling the picker leaves prefs untouched so it
|
||||
re-prompts next launch (lets the user install Century Gothic and retry).
|
||||
"""
|
||||
from PyQt6.QtWidgets import QMessageBox, QFontDialog
|
||||
|
||||
from lintunes import theme
|
||||
desired = theme.now_playing_font_family(prefs)
|
||||
if desired is None and prefs.get("now_playing_font") == "":
|
||||
return # user already chose the app default
|
||||
if desired is not None:
|
||||
return # resolved to an installed family (CG or a saved substitute)
|
||||
|
||||
saved = prefs.get("now_playing_font")
|
||||
if saved is None:
|
||||
msg = ("LinTunes looks best with Century Gothic, which isn't installed. "
|
||||
"Install it (~/.local/share/fonts/) and restart, or pick a font "
|
||||
"to use for the now-playing panel now.")
|
||||
else:
|
||||
msg = (f"The font saved for the now-playing panel ({saved}) is no "
|
||||
"longer installed. Pick a different font, or use the app default.")
|
||||
box = QMessageBox(QMessageBox.Icon.Warning, "Now-playing font", msg)
|
||||
pick = box.addButton("Pick a font…", QMessageBox.ButtonRole.AcceptRole)
|
||||
default = box.addButton("Use default", QMessageBox.ButtonRole.RejectRole)
|
||||
box.exec()
|
||||
if box.clickedButton() is default:
|
||||
prefs.set("now_playing_font", "")
|
||||
return
|
||||
if box.clickedButton() is not pick:
|
||||
return # closed via X / escape → leave untouched, re-prompt next run
|
||||
font, ok = QFontDialog.getFont()
|
||||
if not ok or font.family() in ("", None):
|
||||
return
|
||||
prefs.set("now_playing_font", font.family())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
from .track import Track
|
||||
from .playlist import Playlist, PlaylistSettings, PlaylistType
|
||||
from .library import Library
|
||||
|
||||
__all__ = ["Track", "Playlist", "PlaylistSettings", "PlaylistType", "Library"]
|
||||
@@ -0,0 +1,16 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from .track import Track
|
||||
from .playlist import Playlist, PlaylistSettings
|
||||
|
||||
|
||||
@dataclass
|
||||
class Library:
|
||||
tracks: dict[int, Track] = field(default_factory=dict) # track_id -> Track
|
||||
playlists: dict[str, Playlist] = field(default_factory=dict) # persistent_id -> Playlist
|
||||
music_folder: str = ""
|
||||
import_date: Optional[str] = None
|
||||
# Column/sort settings for the all-tracks Library view
|
||||
library_settings: PlaylistSettings = field(
|
||||
default_factory=lambda: PlaylistSettings(sort_column="artist"))
|
||||
@@ -0,0 +1,117 @@
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class PlaylistType(Enum):
|
||||
REGULAR = "regular"
|
||||
FOLDER = "folder"
|
||||
SMART = "smart"
|
||||
SYSTEM = "system" # Master, Music, etc.
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaylistSettings:
|
||||
visible_columns: list[str] = field(default_factory=lambda: [
|
||||
"name", "artist", "album", "genre", "total_time", "year",
|
||||
"play_count", "rating", "date_added",
|
||||
])
|
||||
sort_column: str = "#" # "#" = manual play order
|
||||
sort_ascending: bool = True
|
||||
column_widths: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"visible_columns": self.visible_columns,
|
||||
"sort_column": self.sort_column,
|
||||
"sort_ascending": self.sort_ascending,
|
||||
"column_widths": self.column_widths,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "PlaylistSettings":
|
||||
return cls(
|
||||
visible_columns=d.get("visible_columns", [
|
||||
"name", "artist", "album", "genre", "total_time", "year",
|
||||
"play_count", "rating", "date_added",
|
||||
]),
|
||||
sort_column=d.get("sort_column", "#"),
|
||||
sort_ascending=d.get("sort_ascending", True),
|
||||
column_widths=d.get("column_widths", {}),
|
||||
)
|
||||
|
||||
|
||||
# Keys that indicate system/special playlists. Note: "All Items" is present
|
||||
# on every playlist in the XML, so it is NOT a system marker.
|
||||
SYSTEM_PLAYLIST_FLAGS = {
|
||||
"Master", "Distinguished Kind", "Music", "Movies", "TV Shows",
|
||||
"Podcasts", "Audiobooks", "Books", "Purchased Music",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Playlist:
|
||||
name: str = ""
|
||||
persistent_id: str = ""
|
||||
parent_persistent_id: str = ""
|
||||
playlist_type: PlaylistType = PlaylistType.REGULAR
|
||||
track_ids: list[int] = field(default_factory=list)
|
||||
settings: PlaylistSettings = field(default_factory=PlaylistSettings)
|
||||
is_system: bool = False
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = {
|
||||
"name": self.name,
|
||||
"persistent_id": self.persistent_id,
|
||||
"playlist_type": self.playlist_type.value,
|
||||
"track_ids": self.track_ids,
|
||||
"settings": self.settings.to_dict(),
|
||||
"is_system": self.is_system,
|
||||
}
|
||||
if self.parent_persistent_id:
|
||||
d["parent_persistent_id"] = self.parent_persistent_id
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Playlist":
|
||||
return cls(
|
||||
name=d.get("name", ""),
|
||||
persistent_id=d.get("persistent_id", ""),
|
||||
parent_persistent_id=d.get("parent_persistent_id", ""),
|
||||
playlist_type=PlaylistType(d.get("playlist_type", "regular")),
|
||||
track_ids=d.get("track_ids", []),
|
||||
settings=PlaylistSettings.from_dict(d.get("settings", {})),
|
||||
is_system=d.get("is_system", False),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_itunes_dict(cls, itunes_dict: dict) -> "Playlist":
|
||||
name = itunes_dict.get("Name", "")
|
||||
persistent_id = itunes_dict.get("Playlist Persistent ID", "")
|
||||
parent_id = itunes_dict.get("Parent Persistent ID", "")
|
||||
|
||||
# Determine playlist type
|
||||
is_system = any(flag in itunes_dict for flag in SYSTEM_PLAYLIST_FLAGS)
|
||||
if itunes_dict.get("Folder", False):
|
||||
playlist_type = PlaylistType.FOLDER
|
||||
elif "Smart Info" in itunes_dict or "Smart Criteria" in itunes_dict:
|
||||
playlist_type = PlaylistType.SMART
|
||||
elif is_system:
|
||||
playlist_type = PlaylistType.SYSTEM
|
||||
else:
|
||||
playlist_type = PlaylistType.REGULAR
|
||||
|
||||
# Extract track IDs
|
||||
track_ids = []
|
||||
for item in itunes_dict.get("Playlist Items", []):
|
||||
if "Track ID" in item:
|
||||
track_ids.append(item["Track ID"])
|
||||
|
||||
return cls(
|
||||
name=name,
|
||||
persistent_id=persistent_id,
|
||||
parent_persistent_id=parent_id,
|
||||
playlist_type=playlist_type,
|
||||
track_ids=track_ids,
|
||||
is_system=is_system,
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from urllib.parse import unquote
|
||||
|
||||
|
||||
# Mapping from iTunes XML key names to our field names
|
||||
ITUNES_FIELD_MAP = {
|
||||
"Track ID": "track_id",
|
||||
"Name": "name",
|
||||
"Artist": "artist",
|
||||
"Album Artist": "album_artist",
|
||||
"Album": "album",
|
||||
"Genre": "genre",
|
||||
"Kind": "kind",
|
||||
"Size": "size",
|
||||
"Total Time": "total_time",
|
||||
"Disc Number": "disc_number",
|
||||
"Disc Count": "disc_count",
|
||||
"Track Number": "track_number",
|
||||
"Track Count": "track_count",
|
||||
"Year": "year",
|
||||
"Date Modified": "date_modified",
|
||||
"Date Added": "date_added",
|
||||
"Bit Rate": "bit_rate",
|
||||
"Sample Rate": "sample_rate",
|
||||
"Play Count": "play_count",
|
||||
"Play Date UTC": "play_date_utc",
|
||||
"Skip Count": "skip_count",
|
||||
"Skip Date": "skip_date",
|
||||
"Rating": "rating",
|
||||
"Album Rating": "album_rating",
|
||||
"Album Rating Computed": "album_rating_computed",
|
||||
"Loved": "loved",
|
||||
"Composer": "composer",
|
||||
"Grouping": "grouping",
|
||||
"Comments": "comments",
|
||||
"Sort Name": "sort_name",
|
||||
"Sort Artist": "sort_artist",
|
||||
"Sort Album Artist": "sort_album_artist",
|
||||
"Sort Album": "sort_album",
|
||||
"Sort Composer": "sort_composer",
|
||||
"Work": "work",
|
||||
"Movement Name": "movement_name",
|
||||
"Movement Number": "movement_number",
|
||||
"Movement Count": "movement_count",
|
||||
"Persistent ID": "persistent_id",
|
||||
"Track Type": "track_type",
|
||||
"Compilation": "compilation",
|
||||
"BPM": "bpm",
|
||||
"Volume Adjustment": "volume_adjustment",
|
||||
"Start Time": "start_time",
|
||||
"Stop Time": "stop_time",
|
||||
"Equalizer": "equalizer",
|
||||
}
|
||||
|
||||
|
||||
def _datetime_to_iso(dt):
|
||||
if isinstance(dt, datetime):
|
||||
return dt.isoformat()
|
||||
return dt
|
||||
|
||||
|
||||
def _iso_to_datetime(s):
|
||||
if isinstance(s, str):
|
||||
return datetime.fromisoformat(s)
|
||||
return s
|
||||
|
||||
|
||||
@dataclass
|
||||
class Track:
|
||||
track_id: int = 0
|
||||
persistent_id: str = ""
|
||||
name: str = ""
|
||||
artist: str = ""
|
||||
album_artist: str = ""
|
||||
album: str = ""
|
||||
genre: str = ""
|
||||
composer: str = ""
|
||||
grouping: str = ""
|
||||
comments: str = ""
|
||||
kind: str = ""
|
||||
size: int = 0
|
||||
total_time: int = 0 # milliseconds
|
||||
disc_number: int = 0
|
||||
disc_count: int = 0
|
||||
track_number: int = 0
|
||||
track_count: int = 0
|
||||
year: int = 0
|
||||
bpm: int = 0
|
||||
bit_rate: int = 0
|
||||
sample_rate: int = 0
|
||||
play_count: int = 0
|
||||
skip_count: int = 0
|
||||
rating: int = 0
|
||||
album_rating: int = 0
|
||||
album_rating_computed: bool = False
|
||||
loved: bool = False
|
||||
compilation: bool = False
|
||||
volume_adjustment: int = 0
|
||||
start_time: int = 0
|
||||
stop_time: int = 0
|
||||
equalizer: str = ""
|
||||
date_modified: Optional[str] = None # ISO 8601
|
||||
date_added: Optional[str] = None
|
||||
play_date_utc: Optional[str] = None
|
||||
skip_date: Optional[str] = None
|
||||
location: str = "" # Remapped local path
|
||||
track_type: str = ""
|
||||
sort_name: str = ""
|
||||
sort_artist: str = ""
|
||||
sort_album_artist: str = ""
|
||||
sort_album: str = ""
|
||||
sort_composer: str = ""
|
||||
work: str = ""
|
||||
movement_name: str = ""
|
||||
movement_number: int = 0
|
||||
movement_count: int = 0
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = {}
|
||||
for f in self.__dataclass_fields__:
|
||||
val = getattr(self, f)
|
||||
if val != self.__dataclass_fields__[f].default:
|
||||
d[f] = val
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Track":
|
||||
known = {f.name for f in cls.__dataclass_fields__.values()}
|
||||
return cls(**{k: v for k, v in d.items() if k in known})
|
||||
|
||||
@classmethod
|
||||
def from_itunes_dict(cls, itunes_dict: dict, remap_location=None) -> "Track":
|
||||
kwargs = {}
|
||||
for itunes_key, our_key in ITUNES_FIELD_MAP.items():
|
||||
if itunes_key in itunes_dict:
|
||||
val = itunes_dict[itunes_key]
|
||||
if isinstance(val, datetime):
|
||||
val = val.isoformat()
|
||||
kwargs[our_key] = val
|
||||
|
||||
# iTunes "computed" ratings are guesses, not the user's — drop them
|
||||
if itunes_dict.get("Rating Computed"):
|
||||
kwargs.pop("rating", None)
|
||||
if itunes_dict.get("Album Rating Computed"):
|
||||
kwargs.pop("album_rating", None)
|
||||
kwargs.pop("album_rating_computed", None)
|
||||
|
||||
if "Location" in itunes_dict:
|
||||
location = decode_location_url(itunes_dict["Location"])
|
||||
if remap_location is not None:
|
||||
location = remap_location(location)
|
||||
kwargs["location"] = location
|
||||
|
||||
return cls(**kwargs)
|
||||
|
||||
|
||||
def decode_location_url(url: str) -> str:
|
||||
"""Decode an iTunes Location URL to a plain absolute path."""
|
||||
location = unquote(url)
|
||||
for prefix in ("file://localhost", "file://"):
|
||||
if location.startswith(prefix):
|
||||
location = location[len(prefix):]
|
||||
break
|
||||
return location
|
||||
@@ -0,0 +1,252 @@
|
||||
"""MPRIS2 D-Bus interface so desktop media keys / playerctl control lintunes.
|
||||
|
||||
Registers org.mpris.MediaPlayer2.lintunes on the session bus with the
|
||||
standard root and Player interfaces, backed by our Player object. Album art
|
||||
is exported to a small cache dir so GNOME's media popup can show it
|
||||
(mpris:artUrl must be a file the notification daemon can read).
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSlot, pyqtProperty, pyqtClassInfo
|
||||
from PyQt6.QtDBus import (
|
||||
QDBusConnection, QDBusAbstractAdaptor, QDBusObjectPath, QDBusMessage,
|
||||
)
|
||||
|
||||
from lintunes import tagging
|
||||
|
||||
|
||||
SERVICE_NAME = "org.mpris.MediaPlayer2.lintunes"
|
||||
OBJECT_PATH = "/org/mpris/MediaPlayer2"
|
||||
|
||||
ART_CACHE_LIMIT = 50
|
||||
|
||||
|
||||
def _art_cache_dir() -> Path:
|
||||
base = os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))
|
||||
return Path(base) / "lintunes" / "artwork"
|
||||
|
||||
|
||||
def export_artwork(track) -> str | None:
|
||||
"""Write the track's embedded art to the cache dir; return a file:// URL."""
|
||||
if track is None or not track.location or not track.persistent_id:
|
||||
return None
|
||||
cache_dir = _art_cache_dir()
|
||||
target = cache_dir / f"{track.persistent_id}.jpg"
|
||||
if not target.exists():
|
||||
data = tagging.read_embedded_artwork(track.location)
|
||||
if not data:
|
||||
return None
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
_prune_cache(cache_dir)
|
||||
tmp = target.with_suffix(".tmp")
|
||||
tmp.write_bytes(data)
|
||||
tmp.replace(target)
|
||||
return "file://" + str(target)
|
||||
|
||||
|
||||
def _prune_cache(cache_dir: Path):
|
||||
files = sorted(cache_dir.glob("*.jpg"), key=lambda p: p.stat().st_mtime)
|
||||
for old in files[:max(0, len(files) - ART_CACHE_LIMIT)]:
|
||||
try:
|
||||
old.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def setup_mpris(player, main_window) -> "MprisService | None":
|
||||
bus = QDBusConnection.sessionBus()
|
||||
if not bus.isConnected():
|
||||
return None
|
||||
service = MprisService(player, main_window)
|
||||
if not bus.registerService(SERVICE_NAME):
|
||||
return None
|
||||
bus.registerObject(OBJECT_PATH, service,
|
||||
QDBusConnection.RegisterOption.ExportAdaptors)
|
||||
return service
|
||||
|
||||
|
||||
class MprisService(QObject):
|
||||
def __init__(self, player, main_window):
|
||||
super().__init__()
|
||||
self.root_adaptor = MprisRootAdaptor(self, main_window)
|
||||
self.player_adaptor = MprisPlayerAdaptor(self, player)
|
||||
|
||||
|
||||
@pyqtClassInfo("D-Bus Interface", "org.mpris.MediaPlayer2")
|
||||
class MprisRootAdaptor(QDBusAbstractAdaptor):
|
||||
def __init__(self, parent, main_window):
|
||||
super().__init__(parent)
|
||||
self._window = main_window
|
||||
|
||||
@pyqtProperty(bool)
|
||||
def CanQuit(self):
|
||||
return True
|
||||
|
||||
@pyqtProperty(bool)
|
||||
def CanRaise(self):
|
||||
return True
|
||||
|
||||
@pyqtProperty(bool)
|
||||
def HasTrackList(self):
|
||||
return False
|
||||
|
||||
@pyqtProperty(str)
|
||||
def Identity(self):
|
||||
return "LinTunes"
|
||||
|
||||
@pyqtProperty(str)
|
||||
def DesktopEntry(self):
|
||||
return "lintunes"
|
||||
|
||||
@pyqtProperty("QStringList")
|
||||
def SupportedUriSchemes(self):
|
||||
return ["file"]
|
||||
|
||||
@pyqtProperty("QStringList")
|
||||
def SupportedMimeTypes(self):
|
||||
return ["audio/mpeg", "audio/mp4", "audio/flac"]
|
||||
|
||||
@pyqtSlot()
|
||||
def Raise(self):
|
||||
self._window.show()
|
||||
self._window.raise_()
|
||||
self._window.activateWindow()
|
||||
|
||||
@pyqtSlot()
|
||||
def Quit(self):
|
||||
self._window.close()
|
||||
|
||||
|
||||
@pyqtClassInfo("D-Bus Interface", "org.mpris.MediaPlayer2.Player")
|
||||
class MprisPlayerAdaptor(QDBusAbstractAdaptor):
|
||||
def __init__(self, parent, player):
|
||||
super().__init__(parent)
|
||||
self._player = player
|
||||
player.playing_changed.connect(lambda _: self._notify(
|
||||
{"PlaybackStatus": self.PlaybackStatus}))
|
||||
player.track_changed.connect(lambda _: self._notify(
|
||||
{"Metadata": self._metadata(), "PlaybackStatus": self.PlaybackStatus}))
|
||||
|
||||
# -- properties --
|
||||
|
||||
@pyqtProperty(str)
|
||||
def PlaybackStatus(self):
|
||||
if self._player.is_playing():
|
||||
return "Playing"
|
||||
if self._player.current_track is not None:
|
||||
return "Paused"
|
||||
return "Stopped"
|
||||
|
||||
@pyqtProperty(float)
|
||||
def Rate(self):
|
||||
return 1.0
|
||||
|
||||
@pyqtProperty(float)
|
||||
def MinimumRate(self):
|
||||
return 1.0
|
||||
|
||||
@pyqtProperty(float)
|
||||
def MaximumRate(self):
|
||||
return 1.0
|
||||
|
||||
@pyqtProperty(float)
|
||||
def Volume(self):
|
||||
return 1.0
|
||||
|
||||
@pyqtProperty("qlonglong")
|
||||
def Position(self):
|
||||
return self._player.position_ms() * 1000 # microseconds
|
||||
|
||||
@pyqtProperty("QVariantMap")
|
||||
def Metadata(self):
|
||||
return self._metadata()
|
||||
|
||||
@pyqtProperty(bool)
|
||||
def CanGoNext(self):
|
||||
return True
|
||||
|
||||
@pyqtProperty(bool)
|
||||
def CanGoPrevious(self):
|
||||
return True
|
||||
|
||||
@pyqtProperty(bool)
|
||||
def CanPlay(self):
|
||||
return True
|
||||
|
||||
@pyqtProperty(bool)
|
||||
def CanPause(self):
|
||||
return True
|
||||
|
||||
@pyqtProperty(bool)
|
||||
def CanSeek(self):
|
||||
return True
|
||||
|
||||
@pyqtProperty(bool)
|
||||
def CanControl(self):
|
||||
return True
|
||||
|
||||
# -- methods --
|
||||
|
||||
@pyqtSlot()
|
||||
def Next(self):
|
||||
self._player.next()
|
||||
|
||||
@pyqtSlot()
|
||||
def Previous(self):
|
||||
self._player.previous()
|
||||
|
||||
@pyqtSlot()
|
||||
def Pause(self):
|
||||
self._player.pause()
|
||||
|
||||
@pyqtSlot()
|
||||
def PlayPause(self):
|
||||
self._player.toggle_play()
|
||||
|
||||
@pyqtSlot()
|
||||
def Play(self):
|
||||
self._player.play()
|
||||
|
||||
@pyqtSlot()
|
||||
def Stop(self):
|
||||
self._player.stop()
|
||||
|
||||
@pyqtSlot("qlonglong")
|
||||
def Seek(self, offset_us):
|
||||
self._player.seek(self._player.position_ms() + offset_us // 1000)
|
||||
|
||||
@pyqtSlot(QDBusObjectPath, "qlonglong")
|
||||
def SetPosition(self, track_id, position_us):
|
||||
self._player.seek(position_us // 1000)
|
||||
|
||||
@pyqtSlot(str)
|
||||
def OpenUri(self, uri):
|
||||
pass
|
||||
|
||||
# -- helpers --
|
||||
|
||||
def _metadata(self) -> dict:
|
||||
track = self._player.current_track
|
||||
if track is None:
|
||||
return {"mpris:trackid": QDBusObjectPath("/org/lintunes/track/none")}
|
||||
meta = {
|
||||
"mpris:trackid": QDBusObjectPath(f"/org/lintunes/track/{track.track_id}"),
|
||||
"xesam:title": track.name or "",
|
||||
"xesam:artist": [track.artist or ""],
|
||||
"xesam:album": track.album or "",
|
||||
}
|
||||
if track.total_time:
|
||||
meta["mpris:length"] = track.total_time * 1000 # microseconds
|
||||
if track.location:
|
||||
meta["xesam:url"] = "file://" + track.location
|
||||
art_url = export_artwork(track)
|
||||
if art_url:
|
||||
meta["mpris:artUrl"] = art_url
|
||||
return meta
|
||||
|
||||
def _notify(self, changed: dict):
|
||||
msg = QDBusMessage.createSignal(
|
||||
OBJECT_PATH, "org.freedesktop.DBus.Properties", "PropertiesChanged")
|
||||
msg.setArguments(["org.mpris.MediaPlayer2.Player", changed, []])
|
||||
QDBusConnection.sessionBus().send(msg)
|
||||
@@ -0,0 +1,293 @@
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QObject, QUrl, pyqtSignal
|
||||
from PyQt6.QtMultimedia import (
|
||||
QMediaPlayer, QAudioOutput, QAudioBufferOutput, QMediaDevices)
|
||||
|
||||
from lintunes.models import Track
|
||||
|
||||
|
||||
# Hitting "previous" more than this far into a song restarts it instead
|
||||
RESTART_THRESHOLD_MS = 3000
|
||||
|
||||
|
||||
def make_shuffle_order(count: int, start_index: int) -> list[int]:
|
||||
"""A random permutation of range(count) that begins at start_index."""
|
||||
order = [i for i in range(count) if i != start_index]
|
||||
random.shuffle(order)
|
||||
if 0 <= start_index < count:
|
||||
order.insert(0, start_index)
|
||||
return order
|
||||
|
||||
|
||||
class Player(QObject):
|
||||
"""Playback engine: wraps QMediaPlayer and walks a queue of track ids.
|
||||
|
||||
The queue is the displayed order of whatever view playback started from.
|
||||
Play counts are recorded (via LibraryManager) when a track finishes
|
||||
playing naturally.
|
||||
"""
|
||||
|
||||
track_changed = pyqtSignal(object) # Track or None
|
||||
playing_changed = pyqtSignal(bool)
|
||||
position_changed = pyqtSignal('qint64') # ms
|
||||
duration_changed = pyqtSignal('qint64') # ms
|
||||
error_occurred = pyqtSignal(str)
|
||||
track_missing = pyqtSignal(object) # Track whose file is gone/moved
|
||||
audio_buffer = pyqtSignal(object) # QAudioBuffer (decoded PCM)
|
||||
track_finished = pyqtSignal(object) # Track played to the very end
|
||||
|
||||
def __init__(self, manager, parent=None):
|
||||
super().__init__(parent)
|
||||
self._manager = manager
|
||||
self._queue: list[int] = []
|
||||
self._index = -1
|
||||
self._current_track: Track | None = None
|
||||
self._shuffle = False
|
||||
self._shuffle_order: list[int] = []
|
||||
# Custom start/stop times (ms) for the loaded track: seek to start once
|
||||
# the media is seekable, and end the track early at stop.
|
||||
self._pending_start_ms = 0
|
||||
self._stop_at_ms = 0
|
||||
self._counted_finish_id: int | None = None
|
||||
|
||||
self._audio = QAudioOutput(self)
|
||||
self._media = QMediaPlayer(self)
|
||||
self._media.setAudioOutput(self._audio)
|
||||
# Tee of the decoded PCM, feeding the visualizer
|
||||
self._buffer_output = QAudioBufferOutput(self)
|
||||
self._media.setAudioBufferOutput(self._buffer_output)
|
||||
self._buffer_output.audioBufferReceived.connect(
|
||||
lambda buf: self.audio_buffer.emit(buf))
|
||||
# Follow the system default output so audio keeps playing when the
|
||||
# active device changes (e.g. headphones unplugged). Held as an
|
||||
# attribute so it isn't garbage-collected and keeps emitting.
|
||||
self._media_devices = QMediaDevices(self)
|
||||
self._media_devices.audioOutputsChanged.connect(
|
||||
self._on_audio_outputs_changed)
|
||||
self._media.positionChanged.connect(self.position_changed)
|
||||
self._media.positionChanged.connect(self._on_position)
|
||||
self._media.durationChanged.connect(self.duration_changed)
|
||||
self._media.playbackStateChanged.connect(self._on_state_changed)
|
||||
self._media.mediaStatusChanged.connect(self._on_media_status)
|
||||
self._media.errorOccurred.connect(self._on_error)
|
||||
|
||||
# ---- queue ----
|
||||
|
||||
def play_queue(self, track_ids: list[int], start_index: int):
|
||||
self._queue = list(track_ids)
|
||||
self._index = start_index
|
||||
self._reshuffle()
|
||||
self._load_current(autoplay=True)
|
||||
|
||||
def set_queue(self, track_ids: list[int]):
|
||||
"""Replace the upcoming order without interrupting the current track."""
|
||||
current_id = self._queue[self._index] if 0 <= self._index < len(self._queue) else None
|
||||
self._queue = list(track_ids)
|
||||
if current_id is not None and current_id in self._queue:
|
||||
self._index = self._queue.index(current_id)
|
||||
self._reshuffle()
|
||||
|
||||
# ---- shuffle ----
|
||||
|
||||
def is_shuffle(self) -> bool:
|
||||
return self._shuffle
|
||||
|
||||
def set_shuffle(self, on: bool):
|
||||
"""Shuffle only changes how we *walk* the queue; the playlist's
|
||||
order and display are untouched."""
|
||||
self._shuffle = bool(on)
|
||||
self._reshuffle()
|
||||
|
||||
def _reshuffle(self):
|
||||
if self._shuffle and self._queue:
|
||||
self._shuffle_order = make_shuffle_order(
|
||||
len(self._queue), max(self._index, 0))
|
||||
else:
|
||||
self._shuffle_order = []
|
||||
|
||||
def _step_index(self, delta: int) -> int | None:
|
||||
"""Queue index `delta` steps away in the active walking order."""
|
||||
if not self._queue:
|
||||
return None
|
||||
if not self._shuffle:
|
||||
candidate = self._index + delta
|
||||
return candidate if 0 <= candidate < len(self._queue) else None
|
||||
try:
|
||||
pos = self._shuffle_order.index(self._index)
|
||||
except ValueError:
|
||||
return self._shuffle_order[0] if self._shuffle_order else None
|
||||
pos += delta
|
||||
if 0 <= pos < len(self._shuffle_order):
|
||||
return self._shuffle_order[pos]
|
||||
return None
|
||||
|
||||
# ---- state ----
|
||||
|
||||
@property
|
||||
def current_track(self) -> Track | None:
|
||||
return self._current_track
|
||||
|
||||
def is_playing(self) -> bool:
|
||||
return self._media.playbackState() == QMediaPlayer.PlaybackState.PlayingState
|
||||
|
||||
def position_ms(self) -> int:
|
||||
return self._media.position()
|
||||
|
||||
def duration_ms(self) -> int:
|
||||
return self._media.duration()
|
||||
|
||||
# ---- transport ----
|
||||
|
||||
def toggle_play(self):
|
||||
if self.is_playing():
|
||||
self._media.pause()
|
||||
elif self._current_track is not None:
|
||||
self._media.play()
|
||||
elif self._queue:
|
||||
self._index = max(self._index, 0)
|
||||
self._load_current(autoplay=True)
|
||||
|
||||
def play(self):
|
||||
if not self.is_playing():
|
||||
self.toggle_play()
|
||||
|
||||
def pause(self):
|
||||
if self.is_playing():
|
||||
self._media.pause()
|
||||
|
||||
def stop(self):
|
||||
self._media.stop()
|
||||
self._current_track = None
|
||||
self.track_changed.emit(None)
|
||||
|
||||
def next(self):
|
||||
# When paused/stopped, skipping only cues the new track; audio
|
||||
# starts only if we were already playing.
|
||||
self._advance(autoplay=self.is_playing())
|
||||
|
||||
def previous(self):
|
||||
if not self._queue:
|
||||
return
|
||||
prev_index = self._step_index(-1)
|
||||
if self._media.position() > RESTART_THRESHOLD_MS or prev_index is None:
|
||||
self._media.setPosition(0)
|
||||
else:
|
||||
self._index = prev_index
|
||||
self._load_current(autoplay=self.is_playing())
|
||||
|
||||
def _advance(self, autoplay: bool):
|
||||
next_index = self._step_index(1)
|
||||
if next_index is not None:
|
||||
self._index = next_index
|
||||
self._load_current(autoplay=autoplay)
|
||||
elif self._queue:
|
||||
self._media.stop()
|
||||
|
||||
def seek(self, position_ms: int):
|
||||
self._media.setPosition(max(0, position_ms))
|
||||
|
||||
def retry_current(self):
|
||||
"""Re-attempt the track at the current queue position (e.g. after its
|
||||
location was repaired). stop() leaves _queue/_index intact."""
|
||||
self._load_current(autoplay=True)
|
||||
|
||||
# ---- internals ----
|
||||
|
||||
def _load_current(self, autoplay: bool):
|
||||
if not (0 <= self._index < len(self._queue)):
|
||||
return
|
||||
track = self._manager.library.tracks.get(self._queue[self._index])
|
||||
if track is None:
|
||||
# The queue references a track that's no longer in the library;
|
||||
# there's nothing to locate, so just report and stop.
|
||||
self.error_occurred.emit(
|
||||
f"Can't play “track {self._queue[self._index]}”.")
|
||||
self.stop()
|
||||
return
|
||||
if not track.location or not Path(track.location).exists():
|
||||
# Stop here rather than auto-advancing: if the whole drive is
|
||||
# unmounted every queued track is missing, and skipping to the
|
||||
# next would recurse through the entire queue (RecursionError).
|
||||
# Stop *before* emitting: the UI shows a modal "Locate File…"
|
||||
# dialog synchronously from this signal and may relocate + retry,
|
||||
# which a later stop() would otherwise undo.
|
||||
self.stop()
|
||||
self.track_missing.emit(track)
|
||||
return
|
||||
self._current_track = track
|
||||
# Arm the custom start/stop times for this track. Stop is honored only
|
||||
# when it falls before the end (otherwise EndOfMedia handles the finish).
|
||||
self._pending_start_ms = track.start_time if track.start_time > 0 else 0
|
||||
if track.stop_time > 0 and (track.total_time == 0
|
||||
or track.stop_time < track.total_time):
|
||||
self._stop_at_ms = track.stop_time
|
||||
else:
|
||||
self._stop_at_ms = 0
|
||||
self._counted_finish_id = None
|
||||
self._media.setSource(QUrl.fromLocalFile(track.location))
|
||||
if autoplay:
|
||||
self._media.play()
|
||||
self.track_changed.emit(track)
|
||||
|
||||
def _skip_unplayable(self, autoplay: bool = True):
|
||||
next_index = self._step_index(1)
|
||||
if next_index is not None:
|
||||
self._index = next_index
|
||||
self._load_current(autoplay)
|
||||
else:
|
||||
self.stop()
|
||||
|
||||
def _on_audio_outputs_changed(self):
|
||||
"""Re-point the output at the current default device when the set of
|
||||
available outputs changes (a device was added or removed)."""
|
||||
new_default = QMediaDevices.defaultAudioOutput()
|
||||
if new_default.isNull() or new_default.id() == self._audio.device().id():
|
||||
return
|
||||
was_playing = self.is_playing()
|
||||
self._audio.setDevice(new_default)
|
||||
# Some backends stall the sink across a device swap; nudge it back.
|
||||
# play() is a no-op if playback actually continued.
|
||||
if was_playing:
|
||||
self._media.play()
|
||||
|
||||
def _on_state_changed(self, state):
|
||||
self.playing_changed.emit(state == QMediaPlayer.PlaybackState.PlayingState)
|
||||
|
||||
def _on_media_status(self, status):
|
||||
if (status == QMediaPlayer.MediaStatus.LoadedMedia
|
||||
and self._pending_start_ms):
|
||||
# The media is only reliably seekable once loaded; jump to the
|
||||
# custom start time now (works whether or not we're autoplaying).
|
||||
start = self._pending_start_ms
|
||||
self._pending_start_ms = 0
|
||||
self._media.setPosition(start)
|
||||
return
|
||||
if status == QMediaPlayer.MediaStatus.EndOfMedia:
|
||||
self._note_finished(self._current_track)
|
||||
# The player has already stopped at EndOfMedia, so force autoplay
|
||||
self._advance(autoplay=True)
|
||||
|
||||
def _on_position(self, position):
|
||||
"""Stop a track at its custom stop time and move on, mirroring a
|
||||
natural finish (play count + scrobble)."""
|
||||
if self._stop_at_ms and position >= self._stop_at_ms:
|
||||
self._stop_at_ms = 0 # one-shot: don't re-fire while advancing
|
||||
track = self._current_track
|
||||
self._note_finished(track)
|
||||
self._advance(autoplay=True)
|
||||
|
||||
def _note_finished(self, track):
|
||||
"""Record a completed play once, guarding against a double count if
|
||||
both the stop-time and end-of-media paths fire for the same track."""
|
||||
if track is None or track.track_id == self._counted_finish_id:
|
||||
return
|
||||
self._counted_finish_id = track.track_id
|
||||
self._manager.record_play(track.track_id)
|
||||
self.track_finished.emit(track)
|
||||
|
||||
def _on_error(self, error, error_string):
|
||||
track_name = self._current_track.name if self._current_track else "?"
|
||||
self.error_occurred.emit(f"Playback error on '{track_name}': {error_string}")
|
||||
self._skip_unplayable()
|
||||
@@ -0,0 +1,73 @@
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
|
||||
|
||||
DEFAULTS = {
|
||||
"ui_scale": "medium", # small | medium | large
|
||||
"highlight": "blue", # see theme.HIGHLIGHT_COLORS
|
||||
"now_playing_font": None, # None=prefer Century Gothic, ""=use app
|
||||
# default, "Family"=user-chosen substitute
|
||||
"lastfm": {
|
||||
"api_key": "",
|
||||
"api_secret": "",
|
||||
"username": "",
|
||||
"session_key": "",
|
||||
"scrobble_enabled": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Preferences(QObject):
|
||||
"""App preferences persisted to <data_dir>/preferences.json (so they ride
|
||||
the same syncthing share as the library)."""
|
||||
|
||||
changed = pyqtSignal()
|
||||
|
||||
def __init__(self, data_dir: Path, parent=None):
|
||||
super().__init__(parent)
|
||||
self._path = Path(data_dir) / "preferences.json"
|
||||
self._data = copy.deepcopy(DEFAULTS)
|
||||
if self._path.exists():
|
||||
try:
|
||||
with open(self._path, "r", encoding="utf-8") as f:
|
||||
stored = json.load(f)
|
||||
_deep_merge(self._data, stored)
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
return self._data.get(key, default)
|
||||
|
||||
def set(self, key: str, value):
|
||||
if self._data.get(key) == value:
|
||||
return
|
||||
self._data[key] = value
|
||||
self.save()
|
||||
self.changed.emit()
|
||||
|
||||
def update_lastfm(self, **fields):
|
||||
self._data["lastfm"].update(fields)
|
||||
self.save()
|
||||
self.changed.emit()
|
||||
|
||||
@property
|
||||
def lastfm(self) -> dict:
|
||||
return self._data["lastfm"]
|
||||
|
||||
def save(self):
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self._path.with_suffix(".json.tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(self._data, f, indent=2)
|
||||
tmp.replace(self._path)
|
||||
|
||||
|
||||
def _deep_merge(base: dict, override: dict):
|
||||
for key, value in override.items():
|
||||
if isinstance(value, dict) and isinstance(base.get(key), dict):
|
||||
_deep_merge(base[key], value)
|
||||
else:
|
||||
base[key] = value
|
||||
@@ -0,0 +1,165 @@
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from lintunes.models import Track, Playlist
|
||||
|
||||
|
||||
# Syncthing conflict file pattern: filename.sync-conflict-20240101-123456-ABCDEFG.json
|
||||
CONFLICT_PATTERN = re.compile(r"^(.+)\.sync-conflict-(\d{8}-\d{6})-\w+(\.\w+)$")
|
||||
|
||||
|
||||
def resolve_conflicts(data_dir: Path):
|
||||
if not data_dir.exists():
|
||||
return
|
||||
|
||||
resolved_dir = data_dir / ".resolved"
|
||||
conflict_files = _find_conflict_files(data_dir)
|
||||
|
||||
if not conflict_files:
|
||||
return
|
||||
|
||||
print(f"Found {len(conflict_files)} Syncthing conflict(s), resolving...")
|
||||
resolved_dir.mkdir(exist_ok=True)
|
||||
|
||||
for conflict_path, original_path in conflict_files:
|
||||
if not original_path.exists():
|
||||
# Original was deleted; just archive the conflict
|
||||
_archive(conflict_path, resolved_dir)
|
||||
continue
|
||||
|
||||
try:
|
||||
if original_path.name == "library.json":
|
||||
_merge_library(original_path, conflict_path)
|
||||
elif original_path.name == "library_metadata.json":
|
||||
_merge_metadata(original_path, conflict_path)
|
||||
elif original_path.parent.name == "playlists":
|
||||
_merge_playlist(original_path, conflict_path)
|
||||
else:
|
||||
# Unknown file type — keep the newer one
|
||||
_keep_newer(original_path, conflict_path)
|
||||
except Exception as e:
|
||||
print(f" Warning: Failed to merge {conflict_path.name}: {e}")
|
||||
|
||||
_archive(conflict_path, resolved_dir)
|
||||
|
||||
print(f" Resolved {len(conflict_files)} conflict(s)")
|
||||
|
||||
|
||||
def _find_conflict_files(data_dir: Path) -> list[tuple[Path, Path]]:
|
||||
results = []
|
||||
for path in data_dir.rglob("*.sync-conflict-*"):
|
||||
match = CONFLICT_PATTERN.match(path.name)
|
||||
if match:
|
||||
original_name = match.group(1) + match.group(3)
|
||||
original_path = path.parent / original_name
|
||||
results.append((path, original_path))
|
||||
return results
|
||||
|
||||
|
||||
def _merge_library(original_path: Path, conflict_path: Path):
|
||||
original = _load_json(original_path)
|
||||
conflict = _load_json(conflict_path)
|
||||
|
||||
for tid, conflict_track_data in conflict.items():
|
||||
if tid not in original:
|
||||
# New track from conflict side
|
||||
original[tid] = conflict_track_data
|
||||
continue
|
||||
|
||||
orig_track = original[tid]
|
||||
_merge_track_fields(orig_track, conflict_track_data)
|
||||
|
||||
_save_json(original_path, original)
|
||||
|
||||
|
||||
def _merge_track_fields(orig: dict, conflict: dict):
|
||||
# play_count, skip_count: take max
|
||||
for field in ("play_count", "skip_count"):
|
||||
if field in conflict:
|
||||
orig[field] = max(orig.get(field, 0), conflict[field])
|
||||
|
||||
# play_date_utc: take most recent
|
||||
for field in ("play_date_utc", "skip_date"):
|
||||
if field in conflict:
|
||||
orig_val = orig.get(field)
|
||||
conf_val = conflict[field]
|
||||
if orig_val is None or (conf_val and conf_val > orig_val):
|
||||
orig[field] = conf_val
|
||||
|
||||
# date_added: take earliest
|
||||
if "date_added" in conflict:
|
||||
orig_val = orig.get("date_added")
|
||||
conf_val = conflict["date_added"]
|
||||
if orig_val is None or (conf_val and conf_val < orig_val):
|
||||
orig["date_added"] = conf_val
|
||||
|
||||
# loved: OR (true wins)
|
||||
if conflict.get("loved", False):
|
||||
orig["loved"] = True
|
||||
|
||||
# rating, metadata: newest date_modified wins
|
||||
orig_mod = orig.get("date_modified")
|
||||
conf_mod = conflict.get("date_modified")
|
||||
if conf_mod and (not orig_mod or conf_mod > orig_mod):
|
||||
# Conflict is newer — take its metadata fields
|
||||
for field in ("rating", "name", "artist", "album_artist", "album",
|
||||
"genre", "composer", "grouping", "comments",
|
||||
"sort_name", "sort_artist", "sort_album_artist",
|
||||
"sort_album", "sort_composer", "date_modified"):
|
||||
if field in conflict:
|
||||
orig[field] = conflict[field]
|
||||
|
||||
|
||||
def _merge_playlist(original_path: Path, conflict_path: Path):
|
||||
original = _load_json(original_path)
|
||||
conflict = _load_json(conflict_path)
|
||||
|
||||
# Union track_ids, order from the newer file
|
||||
orig_ids = set(original.get("track_ids", []))
|
||||
conf_ids = set(conflict.get("track_ids", []))
|
||||
all_ids = orig_ids | conf_ids
|
||||
|
||||
# Determine which file is newer by checking file mtime
|
||||
if conflict_path.stat().st_mtime > original_path.stat().st_mtime:
|
||||
# Use conflict's order as base, append any missing from original
|
||||
base_order = conflict.get("track_ids", [])
|
||||
extra = [tid for tid in original.get("track_ids", []) if tid not in conf_ids]
|
||||
original["track_ids"] = base_order + extra
|
||||
# Settings from newer file
|
||||
if "settings" in conflict:
|
||||
original["settings"] = conflict["settings"]
|
||||
else:
|
||||
# Original is newer — append missing from conflict
|
||||
extra = [tid for tid in conflict.get("track_ids", []) if tid not in orig_ids]
|
||||
original["track_ids"] = original.get("track_ids", []) + extra
|
||||
|
||||
_save_json(original_path, original)
|
||||
|
||||
|
||||
def _merge_metadata(original_path: Path, conflict_path: Path):
|
||||
# Simple: keep the newer file's content
|
||||
_keep_newer(original_path, conflict_path)
|
||||
|
||||
|
||||
def _keep_newer(original_path: Path, conflict_path: Path):
|
||||
if conflict_path.stat().st_mtime > original_path.stat().st_mtime:
|
||||
shutil.copy2(conflict_path, original_path)
|
||||
|
||||
|
||||
def _archive(conflict_path: Path, resolved_dir: Path):
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
archive_name = f"{timestamp}_{conflict_path.name}"
|
||||
shutil.move(str(conflict_path), str(resolved_dir / archive_name))
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _save_json(path: Path, data: dict):
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
@@ -0,0 +1,106 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from lintunes.models import Track, Playlist, PlaylistSettings, Library
|
||||
|
||||
|
||||
def save_library(library: Library, data_dir: Path):
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
save_tracks(library, data_dir)
|
||||
save_metadata(library, data_dir)
|
||||
|
||||
playlists_dir = data_dir / "playlists"
|
||||
playlists_dir.mkdir(exist_ok=True)
|
||||
keep = set()
|
||||
for persistent_id, playlist in library.playlists.items():
|
||||
save_playlist(playlist, data_dir)
|
||||
keep.add(f"{persistent_id}.json")
|
||||
# Remove files for playlists that no longer exist
|
||||
for playlist_file in playlists_dir.glob("*.json"):
|
||||
if playlist_file.name not in keep:
|
||||
playlist_file.unlink()
|
||||
|
||||
|
||||
def save_tracks(library: Library, data_dir: Path):
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
tracks_dict = {str(tid): track.to_dict() for tid, track in library.tracks.items()}
|
||||
_write_json(data_dir / "library.json", tracks_dict)
|
||||
|
||||
|
||||
def save_metadata(library: Library, data_dir: Path):
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
metadata = {
|
||||
"music_folder": library.music_folder,
|
||||
"import_date": library.import_date,
|
||||
"track_count": len(library.tracks),
|
||||
"playlist_count": len(library.playlists),
|
||||
"library_settings": library.library_settings.to_dict(),
|
||||
}
|
||||
_write_json(data_dir / "library_metadata.json", metadata)
|
||||
|
||||
|
||||
def save_playlist(playlist: Playlist, data_dir: Path):
|
||||
playlists_dir = data_dir / "playlists"
|
||||
playlists_dir.mkdir(parents=True, exist_ok=True)
|
||||
_write_json(playlists_dir / f"{playlist.persistent_id}.json", playlist.to_dict())
|
||||
|
||||
|
||||
def delete_playlist_file(persistent_id: str, data_dir: Path):
|
||||
path = data_dir / "playlists" / f"{persistent_id}.json"
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
|
||||
def load_library(data_dir: Path) -> Library:
|
||||
if not data_dir.exists():
|
||||
return Library()
|
||||
|
||||
# Load tracks
|
||||
tracks = {}
|
||||
library_path = data_dir / "library.json"
|
||||
if library_path.exists():
|
||||
tracks_dict = _read_json(library_path)
|
||||
for tid_str, track_data in tracks_dict.items():
|
||||
track = Track.from_dict(track_data)
|
||||
tracks[track.track_id] = track
|
||||
|
||||
# Load metadata
|
||||
music_folder = ""
|
||||
import_date = None
|
||||
library_settings = PlaylistSettings()
|
||||
metadata_path = data_dir / "library_metadata.json"
|
||||
if metadata_path.exists():
|
||||
metadata = _read_json(metadata_path)
|
||||
music_folder = metadata.get("music_folder", "")
|
||||
import_date = metadata.get("import_date")
|
||||
if "library_settings" in metadata:
|
||||
library_settings = PlaylistSettings.from_dict(metadata["library_settings"])
|
||||
|
||||
# Load playlists
|
||||
playlists = {}
|
||||
playlists_dir = data_dir / "playlists"
|
||||
if playlists_dir.exists():
|
||||
for playlist_file in playlists_dir.glob("*.json"):
|
||||
playlist_data = _read_json(playlist_file)
|
||||
playlist = Playlist.from_dict(playlist_data)
|
||||
playlists[playlist.persistent_id] = playlist
|
||||
|
||||
return Library(
|
||||
tracks=tracks,
|
||||
playlists=playlists,
|
||||
music_folder=music_folder,
|
||||
import_date=import_date,
|
||||
library_settings=library_settings,
|
||||
)
|
||||
|
||||
|
||||
def _write_json(path: Path, data: dict):
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Read and write audio file tags (mp3/m4a/flac) via mutagen.
|
||||
|
||||
A small uniform field dict is used throughout:
|
||||
name, artist, album_artist, album, genre, composer, grouping, comments,
|
||||
year (int), track_number, track_count, disc_number, disc_count,
|
||||
compilation (bool), total_time (ms, read-only), bit_rate, sample_rate, size.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
import mutagen
|
||||
from mutagen.easyid3 import EasyID3
|
||||
from mutagen.id3 import ID3, COMM
|
||||
from mutagen.mp3 import MP3
|
||||
from mutagen.mp4 import MP4
|
||||
from mutagen.flac import FLAC
|
||||
|
||||
|
||||
# EasyID3 has no 'comment' key by default; COMM frames need explicit handling.
|
||||
def _comm_get(id3, _key):
|
||||
frame = id3.get("COMM::eng") or id3.get("COMM::XXX")
|
||||
return list(frame.text) if frame else []
|
||||
|
||||
|
||||
def _comm_set(id3, _key, value):
|
||||
id3.delall("COMM")
|
||||
id3.add(COMM(encoding=3, lang="eng", desc="", text=value))
|
||||
|
||||
|
||||
def _comm_delete(id3, _key):
|
||||
id3.delall("COMM")
|
||||
|
||||
|
||||
EasyID3.RegisterKey("comment", _comm_get, _comm_set, _comm_delete)
|
||||
|
||||
# EasyMP4 is missing 'composer' (©wrt) out of the box
|
||||
from mutagen.easymp4 import EasyMP4Tags # noqa: E402
|
||||
if "composer" not in EasyMP4Tags.Set:
|
||||
EasyMP4Tags.RegisterTextKey("composer", "\xa9wrt")
|
||||
|
||||
# Easy-interface tag names shared by EasyID3 / EasyMP4 / FLAC (Vorbis)
|
||||
_TEXT_FIELDS = {
|
||||
"name": "title",
|
||||
"artist": "artist",
|
||||
"album_artist": "albumartist",
|
||||
"album": "album",
|
||||
"genre": "genre",
|
||||
"composer": "composer",
|
||||
"comments": "comment",
|
||||
}
|
||||
|
||||
EDITABLE_FIELDS = list(_TEXT_FIELDS) + [
|
||||
"grouping", "year", "track_number", "track_count",
|
||||
"disc_number", "disc_count", "compilation", "bpm",
|
||||
]
|
||||
|
||||
|
||||
def read_tags(path: str | Path) -> dict:
|
||||
"""Read tags + audio properties into Track-style fields."""
|
||||
audio = mutagen.File(str(path), easy=True)
|
||||
if audio is None:
|
||||
return {}
|
||||
|
||||
fields = {}
|
||||
for our_key, tag_key in _TEXT_FIELDS.items():
|
||||
values = audio.tags.get(tag_key) if audio.tags else None
|
||||
if values:
|
||||
fields[our_key] = str(values[0])
|
||||
|
||||
if audio.tags:
|
||||
for src, num_key, count_key in (("tracknumber", "track_number", "track_count"),
|
||||
("discnumber", "disc_number", "disc_count")):
|
||||
values = audio.tags.get(src)
|
||||
if values:
|
||||
num, _, count = str(values[0]).partition("/")
|
||||
if num.strip().isdigit():
|
||||
fields[num_key] = int(num)
|
||||
if count.strip().isdigit():
|
||||
fields[count_key] = int(count)
|
||||
date_values = audio.tags.get("date") or audio.tags.get("originaldate")
|
||||
if date_values:
|
||||
year_part = str(date_values[0])[:4]
|
||||
if year_part.isdigit():
|
||||
fields["year"] = int(year_part)
|
||||
|
||||
info = audio.info
|
||||
if info is not None:
|
||||
if getattr(info, "length", 0):
|
||||
fields["total_time"] = int(info.length * 1000)
|
||||
if getattr(info, "bitrate", 0):
|
||||
fields["bit_rate"] = info.bitrate // 1000
|
||||
if getattr(info, "sample_rate", 0):
|
||||
fields["sample_rate"] = info.sample_rate
|
||||
fields["size"] = Path(path).stat().st_size
|
||||
fields["kind"] = _kind_for(audio)
|
||||
return fields
|
||||
|
||||
|
||||
def write_tags(path: str | Path, fields: dict):
|
||||
"""Write the given Track-style fields to the file's tags."""
|
||||
path = str(path)
|
||||
audio = mutagen.File(path, easy=True)
|
||||
if audio is None:
|
||||
raise ValueError(f"Unsupported audio file: {path}")
|
||||
if audio.tags is None:
|
||||
audio.add_tags()
|
||||
|
||||
for our_key, tag_key in _TEXT_FIELDS.items():
|
||||
if our_key in fields:
|
||||
value = fields[our_key]
|
||||
if value:
|
||||
audio.tags[tag_key] = [str(value)]
|
||||
else:
|
||||
audio.tags.pop(tag_key, None)
|
||||
|
||||
if "year" in fields:
|
||||
if fields["year"]:
|
||||
audio.tags["date"] = [str(fields["year"])]
|
||||
else:
|
||||
audio.tags.pop("date", None)
|
||||
|
||||
for num_key, count_key, tag_key in (("track_number", "track_count", "tracknumber"),
|
||||
("disc_number", "disc_count", "discnumber")):
|
||||
if num_key in fields or count_key in fields:
|
||||
num = fields.get(num_key, 0)
|
||||
count = fields.get(count_key, 0)
|
||||
if num and count:
|
||||
audio.tags[tag_key] = [f"{num}/{count}"]
|
||||
elif num:
|
||||
audio.tags[tag_key] = [str(num)]
|
||||
else:
|
||||
audio.tags.pop(tag_key, None)
|
||||
|
||||
audio.save()
|
||||
_write_extra_tags(path, fields)
|
||||
|
||||
|
||||
def _write_extra_tags(path: str, fields: dict):
|
||||
"""Fields the easy interfaces don't cover uniformly: grouping, compilation, bpm."""
|
||||
wants = {k for k in ("grouping", "compilation", "bpm") if k in fields}
|
||||
if not wants:
|
||||
return
|
||||
audio = mutagen.File(path)
|
||||
if isinstance(audio, MP3):
|
||||
from mutagen.id3 import GRP1, TCMP, TBPM
|
||||
if "grouping" in fields:
|
||||
audio.tags.delall("GRP1")
|
||||
if fields["grouping"]:
|
||||
audio.tags.add(GRP1(encoding=3, text=[fields["grouping"]]))
|
||||
if "compilation" in fields:
|
||||
audio.tags.delall("TCMP")
|
||||
if fields["compilation"]:
|
||||
audio.tags.add(TCMP(encoding=3, text=["1"]))
|
||||
if "bpm" in fields:
|
||||
audio.tags.delall("TBPM")
|
||||
if fields["bpm"]:
|
||||
audio.tags.add(TBPM(encoding=3, text=[str(fields["bpm"])]))
|
||||
audio.save()
|
||||
elif isinstance(audio, MP4):
|
||||
if "grouping" in fields:
|
||||
_mp4_set(audio, "\xa9grp", fields["grouping"])
|
||||
if "compilation" in fields:
|
||||
audio.tags["cpil"] = bool(fields["compilation"])
|
||||
if "bpm" in fields:
|
||||
_mp4_set(audio, "tmpo", int(fields["bpm"]) if fields["bpm"] else None)
|
||||
audio.save()
|
||||
elif isinstance(audio, FLAC):
|
||||
if "grouping" in fields:
|
||||
_vorbis_set(audio, "grouping", fields["grouping"])
|
||||
if "compilation" in fields:
|
||||
_vorbis_set(audio, "compilation", "1" if fields["compilation"] else "")
|
||||
if "bpm" in fields:
|
||||
_vorbis_set(audio, "bpm", str(fields["bpm"]) if fields["bpm"] else "")
|
||||
audio.save()
|
||||
|
||||
|
||||
def _mp4_set(audio, key, value):
|
||||
if value:
|
||||
audio.tags[key] = [value]
|
||||
else:
|
||||
audio.tags.pop(key, None)
|
||||
|
||||
|
||||
def _vorbis_set(audio, key, value):
|
||||
if value:
|
||||
audio.tags[key] = [value]
|
||||
else:
|
||||
audio.tags.pop(key, None)
|
||||
|
||||
|
||||
def _kind_for(audio) -> str:
|
||||
name = type(audio).__name__
|
||||
return {
|
||||
"EasyMP3": "MPEG audio file",
|
||||
"MP3": "MPEG audio file",
|
||||
"EasyMP4": "AAC audio file",
|
||||
"MP4": "AAC audio file",
|
||||
"FLAC": "FLAC audio file",
|
||||
}.get(name, name)
|
||||
|
||||
|
||||
def write_artwork(path: str | Path, image_bytes: bytes, mime: str = "image/jpeg"):
|
||||
"""Replace the file's embedded cover art (front cover) with image_bytes."""
|
||||
from mutagen.id3 import APIC
|
||||
from mutagen.mp4 import MP4Cover
|
||||
from mutagen.flac import Picture
|
||||
|
||||
audio = mutagen.File(str(path))
|
||||
if audio is None:
|
||||
raise ValueError(f"Unsupported audio file: {path}")
|
||||
if isinstance(audio, MP4):
|
||||
fmt = (MP4Cover.FORMAT_PNG if mime == "image/png"
|
||||
else MP4Cover.FORMAT_JPEG)
|
||||
if audio.tags is None:
|
||||
audio.add_tags()
|
||||
audio.tags["covr"] = [MP4Cover(image_bytes, imageformat=fmt)]
|
||||
elif isinstance(audio, FLAC):
|
||||
picture = Picture()
|
||||
picture.type = 3 # front cover
|
||||
picture.mime = mime
|
||||
picture.desc = "Cover"
|
||||
picture.data = image_bytes
|
||||
audio.clear_pictures()
|
||||
audio.add_picture(picture)
|
||||
elif hasattr(audio, "tags") and (isinstance(audio, MP3) or
|
||||
isinstance(audio.tags, ID3)):
|
||||
if audio.tags is None:
|
||||
audio.add_tags()
|
||||
audio.tags.delall("APIC")
|
||||
audio.tags.add(APIC(encoding=3, mime=mime, type=3, desc="Cover",
|
||||
data=image_bytes))
|
||||
else:
|
||||
raise ValueError(f"Don't know how to embed artwork in {path}")
|
||||
audio.save()
|
||||
|
||||
|
||||
def read_embedded_artwork(path: str | Path) -> bytes | None:
|
||||
"""Return the first embedded cover image's bytes, or None."""
|
||||
try:
|
||||
audio = mutagen.File(str(path))
|
||||
except Exception:
|
||||
return None
|
||||
if audio is None:
|
||||
return None
|
||||
if isinstance(audio, MP4):
|
||||
covers = audio.tags.get("covr") if audio.tags else None
|
||||
if covers:
|
||||
return bytes(covers[0])
|
||||
elif isinstance(audio, FLAC):
|
||||
if audio.pictures:
|
||||
return audio.pictures[0].data
|
||||
elif audio.tags is not None:
|
||||
# ID3 (mp3, aiff): APIC frames
|
||||
for key in audio.tags.keys():
|
||||
if key.startswith("APIC"):
|
||||
return audio.tags[key].data
|
||||
return None
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Tap-tempo math, kept Qt-free so it's unit-testable."""
|
||||
|
||||
|
||||
class TapTempo:
|
||||
RESET_GAP_SECONDS = 2.5
|
||||
MAX_TAPS = 8
|
||||
|
||||
def __init__(self):
|
||||
self._taps: list[float] = []
|
||||
|
||||
def tap(self, now: float) -> int | None:
|
||||
"""Record a tap at monotonic time `now`; returns the current bpm
|
||||
estimate (None until there are two taps in the series)."""
|
||||
if self._taps and now - self._taps[-1] > self.RESET_GAP_SECONDS:
|
||||
self._taps = []
|
||||
self._taps.append(now)
|
||||
self._taps = self._taps[-self.MAX_TAPS:]
|
||||
return self.bpm()
|
||||
|
||||
def bpm(self) -> int | None:
|
||||
if len(self._taps) < 2:
|
||||
return None
|
||||
intervals = [b - a for a, b in zip(self._taps, self._taps[1:])]
|
||||
average = sum(intervals) / len(intervals)
|
||||
if average <= 0:
|
||||
return None
|
||||
return round(60.0 / average)
|
||||
|
||||
@property
|
||||
def tap_count(self) -> int:
|
||||
return len(self._taps)
|
||||
|
||||
def reset(self):
|
||||
self._taps = []
|
||||
@@ -0,0 +1,96 @@
|
||||
"""App-wide theming: highlight color and UI/text scale, driven by Preferences.
|
||||
|
||||
Everything that shows the highlight color (table/sidebar selection, the seek
|
||||
slider's filled groove, the visualizer bars) reads QPalette.Highlight, so
|
||||
recoloring is a palette swap. UI scale adjusts the app font, scrollbar
|
||||
dimensions, and (via MainWindow.apply_ui_metrics) table row heights.
|
||||
"""
|
||||
from PyQt6.QtGui import QPalette, QColor, QFontDatabase
|
||||
|
||||
|
||||
# The font the now-playing panel wants by default. Not bundled (it's a
|
||||
# proprietary Monotype face); the user is asked to install it and, if missing,
|
||||
# to pick a substitute on first run (see main.ensure_now_playing_font).
|
||||
CENTURY_GOTHIC = "Century Gothic"
|
||||
|
||||
|
||||
HIGHLIGHT_COLORS = {
|
||||
"blue": "#3584E4",
|
||||
"marigold": "#F5A623",
|
||||
"magenta": "#D6409F",
|
||||
"seafoam": "#7FD1AE",
|
||||
"gray": "#8A8A8A",
|
||||
}
|
||||
|
||||
UI_SCALES = {
|
||||
"small": {"font_pt": 9, "scrollbar": 10, "row_height": 20, "header_strip": 28,
|
||||
"status_height": 22},
|
||||
"medium": {"font_pt": 11, "scrollbar": 14, "row_height": 24, "header_strip": 34,
|
||||
"status_height": 26},
|
||||
"large": {"font_pt": 13, "scrollbar": 18, "row_height": 28, "header_strip": 40,
|
||||
"status_height": 32},
|
||||
}
|
||||
|
||||
_base_palette = None
|
||||
|
||||
|
||||
def highlight_color(prefs) -> QColor:
|
||||
return QColor(HIGHLIGHT_COLORS.get(prefs.get("highlight"),
|
||||
HIGHLIGHT_COLORS["blue"]))
|
||||
|
||||
|
||||
def scale_metrics(prefs) -> dict:
|
||||
return UI_SCALES.get(prefs.get("ui_scale"), UI_SCALES["medium"])
|
||||
|
||||
|
||||
def apply_theme(app, prefs):
|
||||
global _base_palette
|
||||
if _base_palette is None:
|
||||
_base_palette = QPalette(app.palette())
|
||||
|
||||
color = highlight_color(prefs)
|
||||
# Black text on light highlights, white on dark
|
||||
luminance = (0.299 * color.red() + 0.587 * color.green()
|
||||
+ 0.114 * color.blue()) / 255
|
||||
text = QColor("black") if luminance > 0.6 else QColor("white")
|
||||
|
||||
palette = QPalette(_base_palette)
|
||||
for group in (QPalette.ColorGroup.Active, QPalette.ColorGroup.Inactive):
|
||||
palette.setColor(group, QPalette.ColorRole.Highlight, color)
|
||||
palette.setColor(group, QPalette.ColorRole.HighlightedText, text)
|
||||
palette.setColor(group, QPalette.ColorRole.Accent, color)
|
||||
app.setPalette(palette)
|
||||
|
||||
metrics = scale_metrics(prefs)
|
||||
font = app.font()
|
||||
font.setPointSize(metrics["font_pt"])
|
||||
app.setFont(font)
|
||||
|
||||
bar = metrics["scrollbar"]
|
||||
app.setStyleSheet(f"""
|
||||
QScrollBar:vertical {{ width: {bar}px; }}
|
||||
QScrollBar:horizontal {{ height: {bar}px; }}
|
||||
QScrollBar::handle {{ min-height: {bar * 2}px; min-width: {bar * 2}px; }}
|
||||
""")
|
||||
|
||||
|
||||
def century_gothic_available() -> bool:
|
||||
"""True if the Century Gothic family is installed and resolvable by Qt."""
|
||||
return CENTURY_GOTHIC in QFontDatabase.families()
|
||||
|
||||
|
||||
def now_playing_font_family(prefs) -> str | None:
|
||||
"""Family the now-playing panel should use, or None for the app default.
|
||||
|
||||
prefs["now_playing_font"] semantics:
|
||||
- missing/None : never set → prefer Century Gothic, fall back to default
|
||||
- "" : user chose "use default" → always the app default
|
||||
- "Family" : user picked a family → use it (if still installed)
|
||||
"""
|
||||
saved = prefs.get("now_playing_font")
|
||||
if saved == "":
|
||||
return None
|
||||
if saved: # a previously chosen family
|
||||
return saved if saved in QFontDatabase.families() else None
|
||||
# Never set: prefer Century Gothic when present, else default.
|
||||
return CENTURY_GOTHIC if century_gothic_available() else None
|
||||
@@ -0,0 +1,71 @@
|
||||
"""A tiny closure-based undo/redo stack.
|
||||
|
||||
Each Command stores a human label plus two zero-arg callables — one to undo the
|
||||
change and one to redo it. The change is assumed to have already been applied by
|
||||
the caller, so push() only records it. The stack keeps a bounded history (10
|
||||
levels by default); pushing past the limit silently drops the oldest entry.
|
||||
"""
|
||||
import collections
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
|
||||
|
||||
class Command:
|
||||
def __init__(self, label: str, undo, redo):
|
||||
self.label = label
|
||||
self._undo = undo
|
||||
self._redo = redo
|
||||
|
||||
def undo(self):
|
||||
self._undo()
|
||||
|
||||
def redo(self):
|
||||
self._redo()
|
||||
|
||||
|
||||
class UndoStack(QObject):
|
||||
changed = pyqtSignal()
|
||||
|
||||
def __init__(self, maxlen: int = 10, parent=None):
|
||||
super().__init__(parent)
|
||||
self._undo: collections.deque[Command] = collections.deque(maxlen=maxlen)
|
||||
self._redo: list[Command] = []
|
||||
|
||||
def push(self, command: Command):
|
||||
"""Record an already-applied change; clears the redo branch."""
|
||||
self._undo.append(command)
|
||||
self._redo.clear()
|
||||
self.changed.emit()
|
||||
|
||||
def undo(self):
|
||||
if not self._undo:
|
||||
return
|
||||
command = self._undo.pop()
|
||||
command.undo()
|
||||
self._redo.append(command)
|
||||
self.changed.emit()
|
||||
|
||||
def redo(self):
|
||||
if not self._redo:
|
||||
return
|
||||
command = self._redo.pop()
|
||||
command.redo()
|
||||
self._undo.append(command)
|
||||
self.changed.emit()
|
||||
|
||||
def can_undo(self) -> bool:
|
||||
return bool(self._undo)
|
||||
|
||||
def can_redo(self) -> bool:
|
||||
return bool(self._redo)
|
||||
|
||||
def undo_label(self) -> str:
|
||||
return self._undo[-1].label if self._undo else ""
|
||||
|
||||
def redo_label(self) -> str:
|
||||
return self._redo[-1].label if self._redo else ""
|
||||
|
||||
def clear(self):
|
||||
self._undo.clear()
|
||||
self._redo.clear()
|
||||
self.changed.emit()
|
||||
Reference in New Issue
Block a user