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:
2026-06-26 21:12:01 -04:00
commit b7f8761bd1
70 changed files with 8992 additions and 0 deletions
View File
+26
View File
@@ -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)
View File
+36
View File
@@ -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)
+137
View File
@@ -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
+78
View File
@@ -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,
}
+456
View File
@@ -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()
+305
View File
@@ -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()
+405
View File
@@ -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)
+67
View File
@@ -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
+143
View File
@@ -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", []))
+154
View File
@@ -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', '')}")
+409
View File
@@ -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)
+747
View File
@@ -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)
+360
View File
@@ -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())
+187
View File
@@ -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)
View File
+106
View File
@@ -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
+174
View File
@@ -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
+45
View File
@@ -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
+56
View File
@@ -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())
+199
View File
@@ -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()
+434
View File
@@ -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()
+173
View File
@@ -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()
+5
View File
@@ -0,0 +1,5 @@
from .track import Track
from .playlist import Playlist, PlaylistSettings, PlaylistType
from .library import Library
__all__ = ["Track", "Playlist", "PlaylistSettings", "PlaylistType", "Library"]
+16
View File
@@ -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"))
+117
View File
@@ -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,
)
+166
View File
@@ -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
+252
View File
@@ -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)
+293
View File
@@ -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()
+73
View File
@@ -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
View File
+165
View File
@@ -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)
+106
View File
@@ -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)
+256
View File
@@ -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
+34
View File
@@ -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 = []
+96
View File
@@ -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
+71
View File
@@ -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()