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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 21:12:01 -04:00
commit f6d35fa594
70 changed files with 8992 additions and 0 deletions

20
.gitignore vendored Normal file
View File

@ -0,0 +1,20 @@
# Python
__pycache__/
*.pyc
.pytest_cache/
# Editor backups
*~
# Atomic-write temp files
*.json.tmp
# Trav's real library data (lives in the music folder; moves to the tummult drive)
data/
# iTunes library exports used only for the manual real-import eyeball task
# (~104M of real library metadata; the test suite builds its own small fixtures)
itunes-test-library/
# Local-only Claude settings
.claude/settings.local.json

92
README.md Normal file
View File

@ -0,0 +1,92 @@
# LinTunes
[screenshot]
an mp3 library manager and player for linux. Absolutely no guarantees, if it wrecks your itunes library or wipes your
harddrive that's on you (maybe just have your LLM of choice review the
software for bugs and vulns?).
## Features
- can import an iTunes 12 (untested on other versions) library
## How it works
- Your music files are never moved or rewritten (except when you edit tags).
- The library lives as **plain JSON files** (`library.json` + one file per
playlist) in a directory you choose — designed to be synced with
[Syncthing](https://syncthing.net); sync conflicts are merged automatically
on startup (play counts take the max, edits take the newest, playlists
take the union).
- Playback via Qt Multimedia/FFmpeg (mp3, m4a, flac). Media keys work
through MPRIS. Scrobbling to last.fm is optional (Edit → Preferences).
## Why
I have been a mac user for ~34 years. I gave up daily driving mac os in 2020. I figured Apple
but my music library
even some tracks I got from napster all the way back in 2000 (I have since
paid for!!)
## Quickstart
```sh
pip install -e . # PyQt6, mutagen, numpy, requests
# one-time import from iTunes (XML from iTunes 12.x: File > Library > Export Library)
lintunes --import-xml "iTunes Library.xml" \
--music-root "/path/to/iTunes Media" \
--data-dir /path/to/library-data --save-config
lintunes # run the app
```
### Running it
The `lintunes` command only exists after `pip install -e .`, and it lives in
`~/.local/bin`, so that has to be on your `PATH` (it is by default on most
distros). If `lintunes` isn't found, you can always run it straight from this
checkout without installing — from the project directory:
```sh
python3 -m lintunes.main # same thing the desktop launcher runs
```
Fedora/Debian note: you need the FFmpeg codecs for Qt Multimedia
(`qt6-qtmultimedia` with ffmpeg, usually via RPM Fusion / regular apt).
LinTunes looks best with **Century Gothic** installed (`~/.local/share/fonts/`);
if it's missing, you'll be asked to pick a font on first run.
## Keys
Space play/pause · ←/→ previous/next · Ctrl+B column browser ·
Ctrl+I get info · Ctrl+, preferences · Ctrl+C/Ctrl+V copy/paste tracks ·
double-click sidebar art for a big art window
## App icon
`packaging/install-desktop.sh` installs the launcher entry and icon for your
user (lets you pin LinTunes to the GNOME dash). The icon is just a file —
replace `packaging/lintunes.png` (256×256 PNG) and re-run the script to use
your own. GNOME caches icons, so if the old one lingers, log out and back in.
Unpinning from the dash does **not** uninstall LinTunes — it's still in the
GNOME app grid (open Activities and search "LinTunes"). Right-click it there →
**Pin to Dash** to get it back.
## Development
```sh
python3 -m pytest tests/
```
`spec.md` is the original design brief; `tasks*.md` track what's built.

59
TASKS.md Normal file
View File

@ -0,0 +1,59 @@
# LinTunes — master task list
## Open / future
- [x] confirm last fm works
- [ ] do do a volume slider, this can some space between the visualizer and the timeline.
- [ ] I'd like in the preferences to have a colors adjustment for each area setting: background, round-rec of buttons, the (currently white) behind the track/artist/album text, the color of that text itself, and the color of the gray of the stripes in the tracklisting. Each of those should have a slider from black to white that appears immediately so the user can tweak it to their liking.
- [x] visualizer should have more discrete mode that's a light gray on light gray
- [ ] we need to be able to make smart playlists. They should also be properly imported from itunes. We need all the fields that itunes 12 is able to work with when creating a smart playlist. This is a complicated feature! We also need to be able to edit the criteria of a smart playlist once it is created. Smart playlists should have a little Rotated Floral Heart Bullet (❧) to the left of their title in the playlist list on the left.
- [ ] the ability to not just copy/paste a song but also to ctrl-x cut a song from one space and ctrl-v paste it somewhere else. AND when pasting a song(s) it should paste above the currently selected track. That's where it pastes to, not to the end of the playlist.
- [ ] if the user is dragging a track around and they drag above the top of the track list, the track list should scroll up. Same if they hover the track below the track list at the bottom it scrolls down. The track list should scroll in the direction the user is hovering the track. This helps the user move a track to a place in a playlist that isn't currently seen.
- [ ] the app should be a little more agressive about comandeering the play/pause button from other media playing on the system. If I was playing a youtube video and that was associated with play/pause, but it's been hours since I played it, and I started playing music in lintunes, I would expect the play/pause button to pause the lintunes music when I hit it— not also start playing a youtube video, you know what I mean?
## when we're ready to go live
- [ ] (`tagging.read_embedded_artwork`); it is NOT stored in library.json.
<!-- Context added by Claude (Opus 4.8), 2026-06-19 — the "migrate 100% of my
artwork" question is now ANSWERED & TOOLED, only the grid-view feature remains:
- lintunes shows art from embedded tags only; the importer never touched art.
- Audited the live library vs iTunes' XML "Artwork Count" + the on-disk
`Album Artwork/` cache (scripts/audit_artwork.py, read-only). Result:
13,571 tracks already have embedded art; 790 more are recoverable by
copying an album-mate's embedded art; 0 cache-only and 0 hard residue;
7,015 genuinely have no art; 6 files missing on disk.
=> after recovery, 100% of art-bearing tracks (14,361/14,361) are covered.
- iTunes' `.itc` cache holds NO art that we can't already recover: `Cache/` is
keyed by track persistent_id but only mirrors already-embedded art (0 extra);
`Download/Store` art uses an opaque id, but every track it would cover already
has art via embed or album-propagation, so residue is 0. No `.itl` parsing or
online refetch needed.
- TO FINISH MIGRATION (before/after the live import): run
python3 scripts/recover_artwork.py --data-dir <data> --write
(defaults to --dry-run; embeds the 790 album-propagated covers into the files
via tagging.write_artwork). scripts/audit_artwork.py re-checks coverage.
- `.itc` decoder lives in lintunes/itc.py (tests/test_round9.py). -->
- [ ] **Move data dir** to `/run/media/trav/tummult/music/lintunes/` once trav
confirms the real import looks right (re-run import or copy `./data`, update
config with `--save-config`). Where the data dir is should be a setting in the preferences of
- [ ] Run the real import on trav's library and eyeball the result (fixture in
`./itunes-test-library/`).
1. `python3 scripts/audit_artwork.py --data-dir <data> --xml "<iTunes XML>"`
(read-only) to re-confirm the counts on the freshly-imported library.
2. `python3 scripts/recover_artwork.py --data-dir <data> --dry-run`, review,
then re-run with `--write` to embed the covers into the files.

0
lintunes/__init__.py Normal file
View File

26
lintunes/config.py Normal file
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)

0
lintunes/gui/__init__.py Normal file
View File

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
lintunes/gui/drag_ghost.py Normal file
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
lintunes/gui/icons.py Normal file
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
lintunes/gui/info_dialog.py Normal file
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()

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
lintunes/gui/main_window.py Normal file
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)

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

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", []))

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
lintunes/gui/sidebar.py Normal file
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
lintunes/gui/track_table.py Normal file
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
lintunes/gui/transport.py Normal file
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
lintunes/gui/visualizer.py Normal file
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

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

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
lintunes/inhibit.py Normal file
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
lintunes/itc.py Normal file
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
lintunes/lastfm.py Normal file
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
lintunes/library_manager.py Normal file
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
lintunes/main.py Normal file
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()

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"]

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
lintunes/models/playlist.py Normal file
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
lintunes/models/track.py Normal file
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
lintunes/mpris.py Normal file
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
lintunes/player.py Normal file
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
lintunes/preferences.py Normal file
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

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)

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
lintunes/tagging.py Normal file
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
lintunes/tap_tempo.py Normal file
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
lintunes/theme.py Normal file
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
lintunes/undo.py Normal file
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()

BIN
packaging/drag-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

34
packaging/install-desktop.sh Executable file
View File

@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Install the lintunes .desktop entry and icon for the current user, so GNOME
# shows a proper icon and the app can be pinned to the dash.
# Re-run after swapping packaging/lintunes.png to update the icon.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APPS_DIR="$HOME/.local/share/applications"
ICON_DIR="$HOME/.local/share/icons/hicolor/256x256/apps"
mkdir -p "$APPS_DIR" "$ICON_DIR"
cp "$HERE/lintunes.png" "$ICON_DIR/lintunes.png"
# If lintunes isn't on PATH, point Exec at this checkout's module instead.
# We must NOT rely on the working directory: GNOME's dash launches apps via a
# systemd scope that does not reliably honor the Path= key, so `python3 -m`
# would fail with "No module named 'lintunes'". Put the checkout on PYTHONPATH
# and use absolute python so the launch is independent of CWD and PATH.
PROJECT_DIR="$(dirname "$HERE")"
PY="$(command -v python3 || echo /usr/bin/python3)"
if command -v lintunes >/dev/null 2>&1; then
cp "$HERE/lintunes.desktop" "$APPS_DIR/lintunes.desktop"
else
sed -e "s|^Exec=lintunes|Exec=/usr/bin/env PYTHONPATH=$PROJECT_DIR $PY -m lintunes.main|" \
-e "s|^Terminal=|Path=$PROJECT_DIR\nTerminal=|" \
"$HERE/lintunes.desktop" > "$APPS_DIR/lintunes.desktop"
fi
command -v update-desktop-database >/dev/null 2>&1 && \
update-desktop-database "$APPS_DIR" || true
command -v gtk-update-icon-cache >/dev/null 2>&1 && \
gtk-update-icon-cache -t "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
echo "Installed. Find 'LinTunes' in the app grid; right-click to pin to dash."

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@ -0,0 +1,11 @@
[Desktop Entry]
Type=Application
Name=LinTunes
GenericName=Music Player
Comment=iTunes-style music library manager
Exec=lintunes %F
Icon=lintunes
Terminal=false
Categories=AudioVideo;Audio;Player;
MimeType=audio/mpeg;audio/mp4;audio/x-m4a;audio/flac;audio/x-flac;audio/aac;audio/x-wav;audio/x-aiff;
StartupWMClass=lintunes

BIN
packaging/lintunes.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

4
requirements.txt Normal file
View File

@ -0,0 +1,4 @@
PyQt6>=6.8.0
mutagen>=1.46
numpy>=1.24
requests>=2.28

179
scripts/audit_artwork.py Normal file
View File

@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Audit album-art coverage of an imported lintunes library (read-only).
Answers "how far am I from having 100% of the art iTunes had, and why?" by
classifying every track into recovery tiers (see scripts/recover_artwork.py):
embedded - art is already in the file -> lintunes already shows it
tier2_album - no embedded art, but an album-mate has art we can copy
tier3_cache - no embedded art, but iTunes' Album Artwork/Cache has a
.itc keyed by this track's persistent id
residue - iTunes had art (XML "Artwork Count">=1) but it's only in
Download/Store under an opaque id we can't map from here
none - no art anywhere (genuinely artless track)
file_missing - the audio file isn't on disk
Nothing is written. Use scripts/recover_artwork.py to actually embed art.
Usage:
python3 scripts/audit_artwork.py --data-dir ./data \
--xml "/run/media/trav/tummult/music/iTunes Library.xml"
"""
import argparse
import glob
import json
import os
import plistlib
import sys
from collections import Counter, defaultdict
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from lintunes import tagging # noqa: E402
def album_key(album: str, album_artist: str, artist: str):
"""Group key for album-mate propagation, or None when album is unknown
(we never propagate across an empty/unknown album)."""
album = (album or "").strip().lower()
if not album:
return None
who = (album_artist or artist or "").strip().lower()
return (who, album)
def build_cache_pid_index(artwork_dir: Path) -> set[str]:
"""Persistent ids that have a Cache/*.itc keyed by '<lib>-<PID>.itc'."""
pids = set()
for f in glob.glob(str(artwork_dir / "Cache" / "**" / "*.itc"),
recursive=True):
token = os.path.basename(f).rsplit(".", 1)[0].split("-")[-1].upper()
pids.add(token)
return pids
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--data-dir", required=True, help="lintunes data dir")
parser.add_argument("--xml", required=True, help="iTunes Library XML file")
parser.add_argument("--artwork-dir",
help="iTunes 'Album Artwork' dir "
"(default: <xml folder>/Album Artwork)")
parser.add_argument("--limit", type=int, default=0,
help="audit only the first N tracks (for a quick look)")
parser.add_argument("--examples", type=int, default=15,
help="how many residue examples to print")
args = parser.parse_args()
library_path = Path(args.data_dir) / "library.json"
xml_path = Path(args.xml)
artwork_dir = (Path(args.artwork_dir) if args.artwork_dir
else xml_path.parent / "Album Artwork")
for label, p in (("Library", library_path), ("XML", xml_path)):
if not p.exists():
sys.exit(f"{label} not found: {p}")
print(f"Reading {library_path} ...")
tracks = json.load(open(library_path, encoding="utf-8"))
print(f"Reading {xml_path} ...")
plist = plistlib.load(open(xml_path, "rb"))
xml_art = {}
for t in plist.get("Tracks", {}).values():
pid = (t.get("Persistent ID") or "").upper()
if pid:
xml_art[pid] = t.get("Artwork Count", 0)
print(f"Indexing Cache/*.itc under {artwork_dir} ...")
cache_pids = build_cache_pid_index(artwork_dir) if artwork_dir.exists() \
else set()
print(f" {len(cache_pids)} cache entries keyed by persistent id\n")
items = list(tracks.values())
if args.limit:
items = items[:args.limit]
# Pass 1: which tracks have embedded art, grouped by album.
embedded = {} # pid -> bool
missing = set() # pid (file not on disk)
album_has_art = defaultdict(bool)
pid_album = {}
total = len(items)
for i, t in enumerate(items, 1):
pid = (t.get("persistent_id") or "").upper()
loc = t.get("location") or ""
key = album_key(t.get("album"), t.get("album_artist"), t.get("artist"))
pid_album[pid] = key
if not loc or not os.path.exists(loc):
missing.add(pid)
embedded[pid] = False
continue
has = tagging.read_embedded_artwork(loc) is not None
embedded[pid] = has
if has and key is not None:
album_has_art[key] = True
if i % 2000 == 0:
print(f" scanned {i}/{total} files...")
print(f" scanned {total}/{total} files.\n")
# Pass 2: classify.
buckets = Counter()
residue_examples = []
for t in items:
pid = (t.get("persistent_id") or "").upper()
if pid in missing:
buckets["file_missing"] += 1
continue
if embedded.get(pid):
buckets["embedded"] += 1
continue
key = pid_album.get(pid)
ac = xml_art.get(pid, 0)
if key is not None and album_has_art.get(key):
buckets["tier2_album"] += 1
elif pid in cache_pids:
buckets["tier3_cache"] += 1
elif ac >= 1:
buckets["residue"] += 1
if len(residue_examples) < args.examples:
residue_examples.append(t)
else:
buckets["none"] += 1
order = ["embedded", "tier2_album", "tier3_cache", "residue", "none",
"file_missing"]
recoverable = buckets["tier2_album"] + buckets["tier3_cache"]
art_target = buckets["embedded"] + recoverable + buckets["residue"]
print("=" * 56)
print(f"{'TIER':<14}{'TRACKS':>10} meaning")
print("-" * 56)
desc = {
"embedded": "already shows in lintunes",
"tier2_album": "fix: copy an album-mate's art",
"tier3_cache": "fix: decode Cache/*.itc by persistent id",
"residue": "iTunes had art, only in Download/Store (hard)",
"none": "no art anywhere",
"file_missing": "audio file not on disk",
}
for b in order:
print(f"{b:<14}{buckets[b]:>10} {desc[b]}")
print("-" * 56)
print(f"{'TOTAL':<14}{total:>10}")
print("=" * 56)
print(f"\nArt-bearing tracks (per iTunes + album-mates): {art_target}")
print(f" already embedded : {buckets['embedded']}")
print(f" recoverable now : {recoverable} "
f"(tier2 {buckets['tier2_album']} + tier3 {buckets['tier3_cache']})")
print(f" hard residue : {buckets['residue']}")
if art_target:
covered = buckets["embedded"] + recoverable
print(f" -> after recovery: {covered}/{art_target} "
f"= {100 * covered / art_target:.1f}% of art-bearing tracks")
if residue_examples:
print(f"\nResidue examples (up to {args.examples}):")
for t in residue_examples:
print(f" - {t.get('artist','?')}{t.get('album','?')}"
f"{t.get('name','?')}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""One-time migration: clear iTunes' guessed ("computed") ratings from an
already-imported lintunes library, without touching anything else.
Reads the iTunes XML to find which tracks had `Rating Computed` set, then
zeroes `rating`/`album_rating` for those tracks in <data-dir>/library.json,
matching by persistent ID (track IDs are not stable across exports).
Usage:
python3 scripts/clear_computed_ratings.py \
--xml "itunes-test-library/iTunes Library.xml" --data-dir ./data
"""
import argparse
import json
import plistlib
import sys
from pathlib import Path
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--xml", required=True, help="iTunes Library XML file")
parser.add_argument("--data-dir", required=True, help="lintunes data dir")
parser.add_argument("--dry-run", action="store_true",
help="Report what would change without writing")
args = parser.parse_args()
xml_path = Path(args.xml)
library_path = Path(args.data_dir) / "library.json"
if not xml_path.exists():
sys.exit(f"XML not found: {xml_path}")
if not library_path.exists():
sys.exit(f"Library not found: {library_path}")
print(f"Reading {xml_path}...")
with open(xml_path, "rb") as f:
plist = plistlib.load(f)
computed_rating_pids = set()
computed_album_pids = set()
for itunes_track in plist.get("Tracks", {}).values():
pid = itunes_track.get("Persistent ID")
if not pid:
continue
if itunes_track.get("Rating Computed"):
computed_rating_pids.add(pid)
if itunes_track.get("Album Rating Computed"):
computed_album_pids.add(pid)
print(f"XML: {len(computed_rating_pids)} computed track ratings, "
f"{len(computed_album_pids)} computed album ratings")
with open(library_path, "r", encoding="utf-8") as f:
tracks = json.load(f)
rated_before = sum(1 for t in tracks.values() if t.get("rating"))
cleared = album_cleared = 0
for track in tracks.values():
pid = track.get("persistent_id", "")
if pid in computed_rating_pids and track.get("rating"):
del track["rating"]
cleared += 1
if pid in computed_album_pids:
if track.pop("album_rating", None) is not None:
album_cleared += 1
track.pop("album_rating_computed", None)
rated_after = sum(1 for t in tracks.values() if t.get("rating"))
print(f"Track ratings: {rated_before} before -> {rated_after} after "
f"({cleared} guessed ratings cleared, {album_cleared} album ratings)")
if args.dry_run:
print("Dry run — nothing written.")
return
tmp = library_path.with_suffix(".json.tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(tracks, f, indent=2, ensure_ascii=False)
tmp.replace(library_path)
print(f"Wrote {library_path}")
if __name__ == "__main__":
main()

170
scripts/recover_artwork.py Normal file
View File

@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""Embed album art that iTunes had but never wrote into the audio files.
lintunes shows art read live from each file's embedded tags, so any art that
lived only in iTunes' Album Artwork cache is invisible until it's embedded. This
backfills it, in order of reliability, and writes nothing it isn't sure about:
tier2 (album) for a track with no embedded art, copy a same-album track's
embedded art (iTunes shows one cover per album)
tier3 (cache) decode iTunes' Album Artwork/Cache/<lib>-<PID>.itc (keyed by
the track's persistent id) and embed it
Download/Store-only art (an opaque id with no track link from here) is *not*
guessed at -- it's reported so you can decide separately (see --audit).
Defaults to --dry-run: nothing is modified until you pass --write. Run the audit
first (scripts/audit_artwork.py) to see how many tracks each tier covers.
Usage:
python3 scripts/recover_artwork.py --data-dir ./data # preview
python3 scripts/recover_artwork.py --data-dir ./data --write # apply
"""
import argparse
import glob
import json
import os
import sys
from collections import defaultdict
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from lintunes import tagging # noqa: E402
from lintunes.itc import decode_itc_file # noqa: E402
def album_key(t: dict):
album = (t.get("album") or "").strip().lower()
if not album:
return None
who = (t.get("album_artist") or t.get("artist") or "").strip().lower()
return (who, album)
def build_cache_index(artwork_dir: Path) -> dict[str, str]:
"""persistent id (upper) -> path of its Cache/*.itc."""
index = {}
for f in glob.glob(str(artwork_dir / "Cache" / "**" / "*.itc"),
recursive=True):
token = os.path.basename(f).rsplit(".", 1)[0].split("-")[-1].upper()
index.setdefault(token, f)
return index
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--data-dir", required=True, help="lintunes data dir")
parser.add_argument("--artwork-dir",
help="iTunes 'Album Artwork' dir (default: alongside the "
"music drive root inferred from track locations)")
parser.add_argument("--write", action="store_true",
help="actually embed art (default is a dry run)")
parser.add_argument("--limit", type=int, default=0,
help="process only the first N tracks")
args = parser.parse_args()
dry = not args.write
library_path = Path(args.data_dir) / "library.json"
if not library_path.exists():
sys.exit(f"Library not found: {library_path}")
tracks = json.load(open(library_path, encoding="utf-8"))
items = list(tracks.values())
if args.limit:
items = items[:args.limit]
# Locate the Album Artwork dir: a sibling of "iTunes Media" on the drive.
artwork_dir = Path(args.artwork_dir) if args.artwork_dir else None
if artwork_dir is None:
for t in items:
loc = t.get("location") or ""
idx = loc.find("iTunes Media")
if idx != -1:
artwork_dir = Path(loc[:idx]) / "Album Artwork"
break
if artwork_dir is None or not artwork_dir.exists():
sys.exit("Could not find the 'Album Artwork' dir; pass --artwork-dir.")
print(f"{'DRY RUN' if dry else 'WRITING'} | Album Artwork: {artwork_dir}\n")
cache_index = build_cache_index(artwork_dir)
# Pass 1: embedded-art map + album grouping (one mutagen read per file).
embedded = {}
album_art_source = {} # album key -> a location that has embedded art
for t in items:
loc = t.get("location") or ""
has = bool(loc) and os.path.exists(loc) and \
tagging.read_embedded_artwork(loc) is not None
embedded[id(t)] = has
if has:
key = album_key(t)
if key is not None:
album_art_source.setdefault(key, loc)
# Pass 2: recover.
stats = defaultdict(int)
errors = []
for t in items:
if embedded[id(t)]:
continue
loc = t.get("location") or ""
if not loc or not os.path.exists(loc):
stats["file_missing"] += 1
continue
pid = (t.get("persistent_id") or "").upper()
label = f"{t.get('artist','?')}{t.get('name','?')}"
# tier2: an album-mate has embedded art.
key = album_key(t)
src = album_art_source.get(key) if key is not None else None
if src and src != loc:
data = tagging.read_embedded_artwork(src)
if data:
mime = "image/png" if data[:8] == b"\x89PNG\r\n\x1a\n" \
else "image/jpeg"
stats["tier2_album"] += 1
if not dry:
try:
tagging.write_artwork(loc, data, mime)
except Exception as e: # noqa: BLE001
errors.append((label, repr(e)))
stats["tier2_album"] -= 1
stats["error"] += 1
continue
# tier3: this track's own Cache/*.itc.
itc = cache_index.get(pid)
if itc:
decoded = decode_itc_file(itc)
if decoded is None:
stats["tier3_raw_skip"] += 1 # ARGb/PNGf raw, can't recover
continue
data, mime = decoded
stats["tier3_cache"] += 1
if not dry:
try:
tagging.write_artwork(loc, data, mime)
except Exception as e: # noqa: BLE001
errors.append((label, repr(e)))
stats["tier3_cache"] -= 1
stats["error"] += 1
continue
stats["unrecovered"] += 1
print("Result:")
for k in ("tier2_album", "tier3_cache", "tier3_raw_skip", "unrecovered",
"file_missing", "error"):
print(f" {k:<16}{stats[k]:>8}")
recovered = stats["tier2_album"] + stats["tier3_cache"]
verb = "would embed" if dry else "embedded"
print(f"\n{verb} art on {recovered} tracks.")
if dry:
print("Re-run with --write to apply.")
if errors:
print(f"\n{len(errors)} write error(s):")
for label, err in errors[:20]:
print(f" - {label}: {err}")
if __name__ == "__main__":
main()

22
setup.py Normal file
View File

@ -0,0 +1,22 @@
from setuptools import setup, find_packages
setup(
name="lintunes",
version="0.1.0",
packages=find_packages(),
install_requires=[
"PyQt6>=6.8.0", # QAudioBufferOutput (visualizer) needs Qt 6.8+
"mutagen>=1.46",
"numpy>=1.24",
"requests>=2.28",
],
data_files=[
("share/applications", ["packaging/lintunes.desktop"]),
],
entry_points={
"console_scripts": [
"lintunes=lintunes.main:main",
],
},
python_requires=">=3.9",
)

33
spec.md Normal file
View File

@ -0,0 +1,33 @@
ok, so, I have a big greenfield project for us. I want us to code up a replacement for itunes on linux. The context is that I've used itunes for 25+ years, I have hundreds of playlists and I rely on the specific UI of itunes. I have not found an existing replacement mp3 playing/library management program for linux which has all the features from itunes I want. So, I think we can write it together. I'd like to start by creating a bare minimum MVP scaffold that we can add features to as we go.
## Key details:
1. we need to be able to import an itunes library from itunes. 12.8.3.1. I want to be able to copy over all the data itunes keeps track of (I think? I don't actually know what data itunes keeps track of but I know the data that *I* use in itunes. I want 'last-played' for all the songs, album art... not sure what else is necessary. File information like date-added.
2. where we store the library data needs to be easily synced via syncthing. I don't want to have to manage complicated databases. I'd love to keep the library as a file (or files) that are easily synced. I don't need to be able to edit the file simultaneously from multiple machines but it should be able to elegantly handle conflicts in the files. This is probably the most complicated part so I wonder if there's a library we can utilize somewhere? This might be the first part to research, we'll want to nail it from the beginning.
2b. The folder of mp3s (etc) will also be synced via syncthing.
3. I want to keep the layout largely the same: left sidebar and righthand playlist view.
4. The left sidebar should include 'library' where we have the 3 column: genre/artist/album view that can be toggled with ctrl-b (for browse)
5. playlist view should have customizable columns, user decides which ones are visible and this setting is set per-playlist. Can sort each playlist by each of these different columns, which is also saved per playlist (saved which column it is currently sorted by). The actual play-order is also saved per-playlist and is not affected by temporarily re-sorting by another criteria.
6. user can add folders for playlists in the left sidebar. Otherwise playlists are sorted alphabetically
7. the application should be as universally compatible on linux as possible, would be great to use it on older systems, but modern fedora compatibility is fine. It should be a gui app just like regular itunes and support window re-sizing.
8. play/pause should be controlled by spacebar, skip track forward/back should be controlled by arrow keys, and this should appear to linux as a regular media player so we can use media keys.
9. songs can be dragged from any playlist or from the library to any other playlist to add them to the playlist
9b. it should also support copy/pasting of songs from one playlist to another
10. songs can be imported to the library by dragging them into any playlist or opening them with the app. Once opened by the app, the mp3 files should be copied to a directory that is kept organized by the app by artist/album as identified by the ID3 tags. So if a song file, from outside of the app, is opened in the app, that file is copied in.
11. this program should support mp3, m4a and flac playback.
12. there should be a standard playback timeline that the user can scrub around to set the place in the song they want to listen to. There should also be play/pause and next/back buttons visible in the top left.
13. we need to be able to edit the ID3 tags of each song through some 'info' menu we can activate somehow.
14. we have no need for the itunes store or the visualizer or movie playback or radio or the mini player or cd burning or ipod syncing. Not every feature will need to be copied.
## how to do it
First of all, please let me know if you have any questions so I can clarify this project for you.
Second, I'd like you to translate this spec into a tasks.md file that makes sense to you. I'll work with you from time to time to work through the tasks to get us to a working program.
Once we're ready to start programming I suggest we start with just details 1 and 2. Let's research them and make sure we have a solid choice before moving forward. So at first we'll just have an itunes database translation/import tool. I'll want to run it on my existing database to see if it works. It's a tall order! Sorry it's such a hard place to start but I think this is the crucial detail, migrating my old stuff. Let me know if you think we should actually start by approaching this another way.
To verify that the import worked maybe we could at least get the library and playlist view working. We don't need playback, or editing or anything. We can at least just display the imported library to confirm it's all there.

102
tasks-done.md Normal file
View File

@ -0,0 +1,102 @@
## Done
All four implemented in Round 8 (2026-06-19); see tests/test_round8.py.
- [x] the ability to right click on any track and within the right-click menu that comes up there's an item that says "show in playlist..." with a right arrow. Hover that and you get a list of all the playlists that that track appears in. Select a playlist and the app jumps to that playlist and highlights the first instance of that song in that playlist.
- [x] ability to edit the ID3 tags of multiple files at once and have it intelligently only modify only the id3 tags that the files share. Say we're editing all songs in an albumb: the song title isn't editable because it isn't the same between the tracks, but the album title would show and the artist. So we could make those changes to all songs at once. _(iTunes-style: differing fields show a "Mixed" placeholder but stay editable; only fields you actually touch are written to all selected tracks.)_
- [x] search, we need search. It only needs to appear in the library view. This should be a text box that takes up a third of that empty horizontal space between the tracks view and the top control panel. It should be a textbox that has in very light gray text that says "search". When you click into it that the "search" text disappears. As soon as you start typing it starts filtering the music. This should be compatible with the browser columns so the browser columns still show genres, artists and albums that match the search term. The search should search all fields for the search term(s). I could see this being really slow having to search all fields of all tracks at every keypress. So we can pause between key-presses if we want. So it's a search but really more like a filter. _(250 ms debounce.)_
- [x] I want support for custom start and end times of tracks like itunes has. We need to be able to import this data from how it's stored in the itunes library and we need to be able to respect it during playback. There should also be fields in the 'get info' screen that allows us to turn custom start/end times of the track on and off and set the value of how far into the song it starts/ends. This is a feature in itunes we're implementing. _(Import was already wired; added Get Info fields + playback seek-to-start/stop-at-stop. Stored in library JSON only — no standard ID3 frame, same as iTunes.)_
- [x] Search box over the library. _(Round 8.)_
### Rounds 14 (2026-06-12)
- **Spec 114** — full iTunes-replacement baseline:
- iTunes XML import with Mac→local location remap, play counts/ratings/dates
preserved, smart + system playlists skipped (`importers/itunes_importer.py`).
- Syncthing-friendly JSON storage, conflict-file merge on startup
(`storage/`); data dir lives in the synced music folder.
- iTunes-style layout: sidebar + track view; genre/artist/album browser
(Ctrl+B); per-playlist columns/sort/manual order; playlist folders.
- PyQt6 GUI; playback (mp3/m4a/flac via Qt Multimedia); scrubbing timeline +
transport buttons; Space/←/→ keys + MPRIS2 media keys.
- Drag/copy-paste tracks; drop-to-import files/folders; Get Info tag editing
via mutagen.
- **Round 2** — art paste in Get Info; BPM tap button; guessed ratings dropped
+ migrated; big-art window; 60fps EQ visualizer; cue-don't-play arrows;
now-playing speaker icon.
- **Round 3** — Preferences (`preferences.py`, Edit ▸ Preferences, Ctrl+,);
Last.fm scrobbling (`lastfm.py`, full-play-through only); theming
(`theme.py`: 5 highlight colors, 3 UI scales); shuffle (walk-order);
sidebar restructure (Library button + bottom art); MPRIS artUrl; painted
transport glyphs; drag fix; app icon + `install-desktop.sh`.
- **Round 4** — drag chip + glowing drop line; Library button border; 8px BPM
box; click-to-jump seek slider; vertical browser splitter; fixed-baseline
visualizer (this also resolved the "vibrating bar bottoms" complaint).
### Round 5 (2026-06-13) — UI polish + status bar + browser normalization
- [x] **Visualizer rounded corners** — rounded gray panel + 1px `palette(mid)`
border, bars clipped to the rounded shape (`gui/visualizer.py`).
- [x] **BPM button** flat inside its box, matching the transport icons
(`gui/transport.py`).
- [x] **Bottom status bar** — permanent totals readout `N tracks · DD:HH:MM:SS`
(trimmed leading units) for the visible track set; reflects browser
genre/artist/album selection and playlist contents; transient
import/scrobble/error messages still shown; height scales with UI size
(`gui/track_table.py` `format_total_time`/`tracks_changed`/`total_stats`,
`gui/main_window.py`, `theme.py` `status_height`).
- [x] **Browser normalization** — merge case variants (display the
most-tracks spelling; ties: more capitals, then alphabetical) for genres,
artists, albums; ignore leading "The" when sorting artists; case-insensitive
filtering so a canonical entry matches all variants (`gui/library_view.py`).
- [x] **Header bold bug**`setHighlightSections(False)`; column headers no
longer bold when playback sets the current cell (`gui/track_table.py`).
- [x] **Library button** — light-gray rounded button, not bold
(`gui/sidebar.py`).
- [x] **Playlist title** — name only; count/time moved to the status bar
(`gui/playlist_view.py`).
- [x] Tests: `tests/test_round5.py` (format_total_time, _canonical_values,
_artist_sort_key).
### 2026-06-18 — top control panel tweaks
- [x] **Faster tap-BPM commit** — the tapped tempo now saves to the track 3s
after the cursor leaves the button, down from 6s (`gui/transport.py`
`BpmButton.SAVE_DELAY_MS`).
- [x] **Control panel redesign** — bar height floored at `BAR_HEIGHT` (84px,
~10% under the old ~95px) via `setMinimumHeight`, giving the center column
vertical slack; the title/artist block is centered with **equal** top/bottom
borders (verified ~11px vs ~10px) using equal `addStretch` above and below,
while the seek row stays pinned to the bottom so the timeline sits low. Key
fix: the **bpm box was moved out of the seek row** into its own box on the far
right (`layout.addWidget(_box(...))`). It was a 37px-tall box that inflated the
timeline row, floating the 15px slider with a lopsided phantom gap below the
artist; out of that row the seek row collapses to slider/label height so the
stretches split evenly. Note: `BAR_HEIGHT` is a *floor* — setting it below the
natural stacked height (~69px) has no effect. EQ visualizer panel background
now matches the transport boxes (`palette(alternate-base)` instead of
`window().darker(115)`). (`gui/transport.py`, `gui/visualizer.py`.) Verified
by offscreen `QWidget.grab()` render + widget-geometry measurement
(gnome-screenshot is blocked under Wayland).
- [ ] **Move data dir** to `/run/media/trav/tummult/music/lintunes/` once trav
confirms the real import looks right (re-run import or copy `./data`, update
config with `--save-config`).
- [ ] Run the real import on trav's library and eyeball the result (fixture in
`./itunes-test-library/`).
- [ ] Album art beyond now-playing (grid/album view); embedded tags only
(decision: no parsing of iTunes' .itc artwork cache).
- [ ] Search box over the library.
- [ ] Background/threaded bulk file import (current import is synchronous).
- [ ] Better duplicate handling on import-by-drop (currently matched by path).
- [ ] Volume control in the transport bar (MPRIS exposes a fixed 1.0).
- [ ] Smart playlists, if ever (skipped at import for now).
- [ ] Wayland edge-drag resize — needs trav's verification; if still bad, try
`lintunes -platform xcb` and switch the .desktop Exec if that fixes it.

72
tests/conftest.py Normal file
View File

@ -0,0 +1,72 @@
import os
import shutil
import subprocess
import pytest
# Let widget-level tests run headless; harmless for the non-GUI tests.
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
@pytest.fixture(scope="session")
def qapp():
"""A Qt application so QObject/QTimer and real widgets work in tests.
Prefers a GUI QApplication (offscreen) so widget tests are possible;
QApplication is a QCoreApplication, so existing non-GUI tests are unaffected.
Falls back to QCoreApplication if no GUI platform is available.
"""
from PyQt6.QtCore import QCoreApplication
inst = QCoreApplication.instance()
if inst is not None:
return inst
try:
from PyQt6.QtWidgets import QApplication
return QApplication([])
except Exception:
return QCoreApplication([])
def _generate_audio(path, codec_args):
"""Generate a 1-second sine-wave audio file with ffmpeg."""
if shutil.which("ffmpeg") is None:
pytest.skip("ffmpeg not available to generate audio fixtures")
result = subprocess.run(
["ffmpeg", "-y", "-f", "lavfi", "-i", "sine=frequency=440:duration=1",
*codec_args, str(path)],
capture_output=True,
)
if result.returncode != 0:
pytest.skip(f"ffmpeg could not generate {path.suffix} fixture")
return path
@pytest.fixture
def mp3_file(tmp_path):
return _generate_audio(tmp_path / "test.mp3", ["-codec:a", "libmp3lame", "-b:a", "64k"])
@pytest.fixture
def m4a_file(tmp_path):
return _generate_audio(tmp_path / "test.m4a", ["-codec:a", "aac", "-b:a", "64k"])
@pytest.fixture
def flac_file(tmp_path):
return _generate_audio(tmp_path / "test.flac", ["-codec:a", "flac"])
@pytest.fixture
def jpeg_bytes(tmp_path):
"""A tiny valid JPEG for artwork tests."""
if shutil.which("ffmpeg") is None:
pytest.skip("ffmpeg not available to generate image fixture")
path = tmp_path / "art.jpg"
result = subprocess.run(
["ffmpeg", "-y", "-f", "lavfi", "-i", "color=red:size=8x8",
"-frames:v", "1", str(path)],
capture_output=True,
)
if result.returncode != 0:
pytest.skip("ffmpeg could not generate jpeg fixture")
return path.read_bytes()

View File

@ -0,0 +1,149 @@
import json
import tempfile
from pathlib import Path
import pytest
from lintunes.storage.conflict_resolver import resolve_conflicts
def _write_json(path: Path, data: dict):
with open(path, "w") as f:
json.dump(data, f)
def _read_json(path: Path) -> dict:
with open(path) as f:
return json.load(f)
class TestConflictResolver:
def test_no_conflicts(self, tmp_path):
data_dir = tmp_path / ".lintunes"
data_dir.mkdir()
_write_json(data_dir / "library.json", {"1": {"track_id": 1, "name": "Song"}})
resolve_conflicts(data_dir)
# Should not crash, data should be unchanged
result = _read_json(data_dir / "library.json")
assert result["1"]["name"] == "Song"
def test_nonexistent_dir(self, tmp_path):
# Should not crash
resolve_conflicts(tmp_path / "nonexistent")
def test_merge_play_counts(self, tmp_path):
data_dir = tmp_path / ".lintunes"
data_dir.mkdir()
original = {
"1": {"track_id": 1, "name": "Song", "play_count": 5, "skip_count": 2}
}
conflict = {
"1": {"track_id": 1, "name": "Song", "play_count": 8, "skip_count": 1}
}
_write_json(data_dir / "library.json", original)
_write_json(
data_dir / "library.sync-conflict-20240101-120000-ABCDEFG.json",
conflict,
)
resolve_conflicts(data_dir)
result = _read_json(data_dir / "library.json")
assert result["1"]["play_count"] == 8 # max(5, 8)
assert result["1"]["skip_count"] == 2 # max(2, 1)
def test_merge_loved_or(self, tmp_path):
data_dir = tmp_path / ".lintunes"
data_dir.mkdir()
original = {"1": {"track_id": 1, "loved": False}}
conflict = {"1": {"track_id": 1, "loved": True}}
_write_json(data_dir / "library.json", original)
_write_json(
data_dir / "library.sync-conflict-20240101-120000-ABCDEFG.json",
conflict,
)
resolve_conflicts(data_dir)
result = _read_json(data_dir / "library.json")
assert result["1"]["loved"] is True
def test_merge_date_added_earliest(self, tmp_path):
data_dir = tmp_path / ".lintunes"
data_dir.mkdir()
original = {"1": {"track_id": 1, "date_added": "2022-06-01T00:00:00"}}
conflict = {"1": {"track_id": 1, "date_added": "2020-01-15T00:00:00"}}
_write_json(data_dir / "library.json", original)
_write_json(
data_dir / "library.sync-conflict-20240101-120000-ABCDEFG.json",
conflict,
)
resolve_conflicts(data_dir)
result = _read_json(data_dir / "library.json")
assert result["1"]["date_added"] == "2020-01-15T00:00:00"
def test_merge_new_track_from_conflict(self, tmp_path):
data_dir = tmp_path / ".lintunes"
data_dir.mkdir()
original = {"1": {"track_id": 1, "name": "Song A"}}
conflict = {
"1": {"track_id": 1, "name": "Song A"},
"2": {"track_id": 2, "name": "Song B"},
}
_write_json(data_dir / "library.json", original)
_write_json(
data_dir / "library.sync-conflict-20240101-120000-ABCDEFG.json",
conflict,
)
resolve_conflicts(data_dir)
result = _read_json(data_dir / "library.json")
assert "2" in result
assert result["2"]["name"] == "Song B"
def test_playlist_merge_union(self, tmp_path):
data_dir = tmp_path / ".lintunes"
playlists_dir = data_dir / "playlists"
playlists_dir.mkdir(parents=True)
original = {"name": "My Playlist", "track_ids": [1, 2, 3], "settings": {}}
conflict = {"name": "My Playlist", "track_ids": [2, 3, 4, 5], "settings": {}}
orig_path = playlists_dir / "PL1.json"
conf_path = playlists_dir / "PL1.sync-conflict-20240101-120000-ABCDEFG.json"
_write_json(orig_path, original)
_write_json(conf_path, conflict)
resolve_conflicts(data_dir)
result = _read_json(orig_path)
# All track IDs should be present (union)
assert set(result["track_ids"]) == {1, 2, 3, 4, 5}
def test_conflicts_archived(self, tmp_path):
data_dir = tmp_path / ".lintunes"
data_dir.mkdir()
_write_json(data_dir / "library.json", {"1": {"track_id": 1}})
conf_path = data_dir / "library.sync-conflict-20240101-120000-ABCDEFG.json"
_write_json(conf_path, {"1": {"track_id": 1}})
resolve_conflicts(data_dir)
assert not conf_path.exists()
resolved_dir = data_dir / ".resolved"
assert resolved_dir.exists()
archived = list(resolved_dir.iterdir())
assert len(archived) == 1

118
tests/test_file_importer.py Normal file
View File

@ -0,0 +1,118 @@
from pathlib import Path
import pytest
from lintunes import tagging
from lintunes.models import Library
from lintunes.library_manager import LibraryManager
from lintunes.importers import file_importer
@pytest.fixture
def manager(qapp, tmp_path):
return LibraryManager(Library(), tmp_path / "data")
class TestOrganizedDestination:
def test_artist_album_layout(self):
dest = file_importer.organized_destination(
Path("/music"), {"artist": "The Band", "album": "Hits"}, "song.mp3")
assert dest == Path("/music/The Band/Hits/song.mp3")
def test_album_artist_preferred(self):
dest = file_importer.organized_destination(
Path("/music"),
{"artist": "Feat Guy", "album_artist": "Main Act", "album": "LP"},
"song.mp3")
assert dest == Path("/music/Main Act/LP/song.mp3")
def test_missing_tags(self):
dest = file_importer.organized_destination(Path("/music"), {}, "song.mp3")
assert dest == Path("/music/Unknown Artist/Unknown Album/song.mp3")
def test_sanitizes_separators(self):
dest = file_importer.organized_destination(
Path("/music"), {"artist": "AC/DC", "album": "Back: In Black?"},
"song.mp3")
assert dest == Path("/music/AC_DC/Back_ In Black_/song.mp3")
class TestImportFile:
def test_copies_and_adds_track(self, manager, mp3_file, tmp_path):
tagging.write_tags(mp3_file, {"name": "My Song", "artist": "Artist X",
"album": "Album Y"})
music_dir = tmp_path / "music"
track = file_importer.import_file(mp3_file, music_dir, manager)
assert track is not None
expected = music_dir / "Artist X" / "Album Y" / "test.mp3"
assert Path(track.location) == expected
assert expected.exists()
assert track.name == "My Song"
assert track.date_added
assert track.track_id in manager.library.tracks
def test_reimport_same_file_is_deduped(self, manager, mp3_file, tmp_path):
music_dir = tmp_path / "music"
track1 = file_importer.import_file(mp3_file, music_dir, manager)
track2 = file_importer.import_file(mp3_file, music_dir, manager)
assert track1.track_id == track2.track_id
assert len(manager.library.tracks) == 1
def test_name_collision_gets_suffix(self, manager, mp3_file, tmp_path):
music_dir = tmp_path / "music"
dest = music_dir / "Unknown Artist" / "Unknown Album" / "test.mp3"
dest.parent.mkdir(parents=True)
dest.write_bytes(b"placeholder different file")
track = file_importer.import_file(mp3_file, music_dir, manager)
assert Path(track.location) == dest.with_stem("test 1")
def test_rejects_non_audio(self, manager, tmp_path):
text = tmp_path / "notes.txt"
text.write_text("hello")
assert file_importer.import_file(text, tmp_path / "music", manager) is None
def test_import_paths_adds_to_playlist(self, manager, mp3_file, tmp_path):
playlist = manager.create_playlist("Drops")
imported = file_importer.import_paths(
[mp3_file], tmp_path / "music", manager, playlist.persistent_id)
assert len(imported) == 1
assert playlist.track_ids == [imported[0].track_id]
class TestLibraryManager:
def test_playlist_crud_and_persistence(self, manager):
folder = manager.create_folder("F")
playlist = manager.create_playlist("P", folder.persistent_id)
manager.rename_playlist(playlist.persistent_id, "P2")
assert manager.library.playlists[playlist.persistent_id].name == "P2"
manager.flush()
path = manager.data_dir / "playlists" / f"{playlist.persistent_id}.json"
assert path.exists()
manager.delete_playlist(folder.persistent_id) # recursive
assert manager.library.playlists == {}
manager.flush()
assert not path.exists()
def test_move_tracks_in_playlist(self, manager):
from lintunes.models import Track
for i in range(1, 6):
manager.add_track(Track(track_id=i, name=f"t{i}"))
playlist = manager.create_playlist("P")
manager.add_tracks_to_playlist(playlist.persistent_id, [1, 2, 3, 4, 5])
# Move rows 0,1 (tracks 1,2) so the block starts at manual row 4
manager.move_tracks_in_playlist(playlist.persistent_id, [0, 1], 4)
assert playlist.track_ids == [3, 4, 1, 2, 5]
# Move last row (track 5) to the top
manager.move_tracks_in_playlist(playlist.persistent_id, [4], 0)
assert playlist.track_ids == [5, 3, 4, 1, 2]
def test_record_play(self, manager):
from lintunes.models import Track
manager.add_track(Track(track_id=7, name="x", play_count=3))
manager.record_play(7)
track = manager.library.tracks[7]
assert track.play_count == 4
assert track.play_date_utc is not None

View File

@ -0,0 +1,184 @@
import plistlib
import tempfile
from datetime import datetime
from pathlib import Path
import pytest
from lintunes.importers.itunes_importer import (
import_itunes_xml, make_location_remapper,
)
MUSIC_FOLDER = "file:///Volumes/Lucia/iTunes/iTunes%20Media/"
MUSIC_ROOT = Path("/run/media/trav/tummult/music/iTunes Media")
def _make_itunes_xml(tracks: dict, playlists: list) -> Path:
plist_data = {
"Major Version": 1,
"Minor Version": 1,
"Application Version": "12.8.3.1",
"Music Folder": MUSIC_FOLDER,
"Tracks": tracks,
"Playlists": playlists,
}
tmp = tempfile.NamedTemporaryFile(suffix=".xml", delete=False)
plistlib.dump(plist_data, tmp, fmt=plistlib.FMT_XML)
tmp.close()
return Path(tmp.name)
def _track(track_id, name, location_suffix, **extra):
return {
"Track ID": track_id,
"Name": name,
"Persistent ID": f"PID{track_id}",
"Track Type": "File",
"Location": MUSIC_FOLDER + location_suffix,
**extra,
}
class TestLocationRemapper:
def test_remaps_music_folder_prefix(self):
remap = make_location_remapper(MUSIC_FOLDER, MUSIC_ROOT)
assert remap("/Volumes/Lucia/iTunes/iTunes Media/Music/A/B/C.m4a") == \
"/run/media/trav/tummult/music/iTunes Media/Music/A/B/C.m4a"
def test_leaves_outside_paths_alone(self):
remap = make_location_remapper(MUSIC_FOLDER, MUSIC_ROOT)
assert remap("/Volumes/Other/song.mp3") == "/Volumes/Other/song.mp3"
def test_localhost_form(self):
remap = make_location_remapper(
"file://localhost/Users/test/Music/iTunes/iTunes%20Media/",
Path("/home/user/Music"))
assert remap("/Users/test/Music/iTunes/iTunes Media/Music/X/Y/Z.mp3") == \
"/home/user/Music/Music/X/Y/Z.mp3"
class TestItunesImporter:
def test_import_basic_track(self):
tracks = {
"100": _track(
100, "Test Song", "Music/Test%20Artist/Test%20Album/Test%20Song.mp3",
Artist="Test Artist", Album="Test Album", Genre="Rock",
**{"Total Time": 200000,
"Date Added": datetime(2020, 1, 1, 0, 0, 0)},
)
}
playlists = [
{
"Name": "Library",
"Master": True,
"Playlist Persistent ID": "MASTER",
"Playlist Items": [{"Track ID": 100}],
}
]
xml_path = _make_itunes_xml(tracks, playlists)
library, report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
assert len(library.tracks) == 1
track = library.tracks[100]
assert track.name == "Test Song"
assert track.artist == "Test Artist"
assert track.location == str(MUSIC_ROOT / "Music/Test Artist/Test Album/Test Song.mp3")
# Master playlist is a system playlist — not imported
assert len(library.playlists) == 0
assert report.skipped_system == 1
assert report.track_count == 1
xml_path.unlink()
def test_import_playlists_skips_smart_and_system(self):
tracks = {
"1": _track(1, "Song A", "Music/A/A/A.mp3"),
"2": _track(2, "Song B", "Music/B/B/B.mp3"),
}
playlists = [
{"Name": "Library", "Master": True,
"Playlist Persistent ID": "MASTER",
"Playlist Items": [{"Track ID": 1}, {"Track ID": 2}]},
{"Name": "Movies", "Distinguished Kind": 2,
"Playlist Persistent ID": "MOVIES"},
{"Name": "Smarty", "Smart Info": b"x", "Smart Criteria": b"y",
"Playlist Persistent ID": "SMART1",
"Playlist Items": [{"Track ID": 1}]},
{"Name": "My Folder", "Folder": True,
"Playlist Persistent ID": "FOLDER1"},
{"Name": "My Playlist", "Playlist Persistent ID": "PL1",
"Parent Persistent ID": "FOLDER1",
"Playlist Items": [{"Track ID": 2}, {"Track ID": 999}]},
]
xml_path = _make_itunes_xml(tracks, playlists)
library, report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
assert set(library.playlists.keys()) == {"FOLDER1", "PL1"}
# Track 999 doesn't exist — dropped from the playlist
assert library.playlists["PL1"].track_ids == [2]
assert library.playlists["PL1"].parent_persistent_id == "FOLDER1"
assert report.skipped_smart == 1
assert report.skipped_system == 2
assert report.playlist_count == 1
assert report.folder_count == 1
xml_path.unlink()
def test_orphaned_child_detached_when_parent_skipped(self):
playlists = [
{"Name": "Inside Smart Folder", "Playlist Persistent ID": "PL1",
"Parent Persistent ID": "GONE"},
]
xml_path = _make_itunes_xml({}, playlists)
library, _report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
assert library.playlists["PL1"].parent_persistent_id == ""
xml_path.unlink()
def test_skips_stream_tracks(self):
tracks = {
"1": {"Track ID": 1, "Name": "Radio", "Track Type": "URL",
"Persistent ID": "R1"},
"2": _track(2, "Real Song", "Music/A/A/A.mp3"),
}
xml_path = _make_itunes_xml(tracks, [])
library, report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
assert set(library.tracks.keys()) == {2}
assert report.skipped_tracks == 1
xml_path.unlink()
def test_unmapped_location_reported(self):
tracks = {
"1": {"Track ID": 1, "Name": "Elsewhere", "Persistent ID": "E1",
"Track Type": "File",
"Location": "file:///Volumes/Other/song.mp3"},
}
xml_path = _make_itunes_xml(tracks, [])
library, report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
assert report.unmapped_locations == ["/Volumes/Other/song.mp3"]
assert 1 in library.tracks # still imported, just flagged
xml_path.unlink()
def test_computed_ratings_dropped(self):
tracks = {
"1": _track(1, "Guessed", "Music/A/A/A.mp3",
**{"Rating": 100, "Rating Computed": True,
"Album Rating": 80, "Album Rating Computed": True}),
"2": _track(2, "Explicit", "Music/B/B/B.mp3",
**{"Rating": 80, "Album Rating": 60}),
}
xml_path = _make_itunes_xml(tracks, [])
library, _report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
assert library.tracks[1].rating == 0
assert library.tracks[1].album_rating == 0
assert library.tracks[2].rating == 80
assert library.tracks[2].album_rating == 60
xml_path.unlink()
def test_import_empty_library(self):
xml_path = _make_itunes_xml({}, [])
library, report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
assert len(library.tracks) == 0
assert len(library.playlists) == 0
assert report.track_count == 0
xml_path.unlink()

175
tests/test_models.py Normal file
View File

@ -0,0 +1,175 @@
import pytest
from datetime import datetime
from lintunes.models import Track, Playlist, PlaylistSettings, PlaylistType, Library
class TestTrack:
def test_to_dict_omits_defaults(self):
track = Track(track_id=1, name="Test Song")
d = track.to_dict()
assert d == {"track_id": 1, "name": "Test Song"}
assert "genre" not in d
assert "play_count" not in d
def test_from_dict_round_trip(self):
track = Track(
track_id=42,
name="My Song",
artist="The Artist",
album="The Album",
genre="Rock",
total_time=180000,
play_count=5,
rating=80,
loved=True,
date_added="2020-01-15T10:30:00",
)
d = track.to_dict()
restored = Track.from_dict(d)
assert restored.track_id == 42
assert restored.name == "My Song"
assert restored.artist == "The Artist"
assert restored.play_count == 5
assert restored.rating == 80
assert restored.loved is True
def test_from_dict_ignores_unknown_fields(self):
d = {"track_id": 1, "name": "Test", "unknown_field": "ignored"}
track = Track.from_dict(d)
assert track.track_id == 1
assert track.name == "Test"
def test_from_itunes_dict(self):
itunes_data = {
"Track ID": 100,
"Name": "iTunes Song",
"Artist": "iTunes Artist",
"Album": "iTunes Album",
"Genre": "Pop",
"Total Time": 240000,
"Play Count": 10,
"Play Date UTC": datetime(2023, 6, 15, 12, 0, 0),
"Date Added": datetime(2020, 3, 1, 8, 0, 0),
"Rating": 100,
"Loved": True,
"Persistent ID": "ABC123",
"Location": "file://localhost/Users/me/Music/Artist/Album/Song.mp3",
}
track = Track.from_itunes_dict(itunes_data)
assert track.track_id == 100
assert track.name == "iTunes Song"
assert track.total_time == 240000
assert track.play_count == 10
assert track.rating == 100
assert track.loved is True
assert track.play_date_utc == "2023-06-15T12:00:00"
assert track.location == "/Users/me/Music/Artist/Album/Song.mp3"
def test_location_decoding(self):
itunes_data = {
"Track ID": 1,
"Location": "file://localhost/Users/me/Music/The%20Artist/My%20Album/01%20Song.mp3",
}
track = Track.from_itunes_dict(itunes_data)
assert track.location == "/Users/me/Music/The Artist/My Album/01 Song.mp3"
def test_location_decoding_volumes_form(self):
# Modern iTunes XML uses file:///Volumes/... URLs
itunes_data = {
"Track ID": 1,
"Location": "file:///Volumes/Lucia/iTunes/iTunes%20Media/Music/A/B/C.m4a",
}
track = Track.from_itunes_dict(itunes_data)
assert track.location == "/Volumes/Lucia/iTunes/iTunes Media/Music/A/B/C.m4a"
class TestPlaylist:
def test_to_dict_from_dict_round_trip(self):
playlist = Playlist(
name="My Favorites",
persistent_id="ABCD1234",
playlist_type=PlaylistType.REGULAR,
track_ids=[1, 2, 3, 4, 5],
)
d = playlist.to_dict()
restored = Playlist.from_dict(d)
assert restored.name == "My Favorites"
assert restored.persistent_id == "ABCD1234"
assert restored.playlist_type == PlaylistType.REGULAR
assert restored.track_ids == [1, 2, 3, 4, 5]
def test_from_itunes_dict_regular(self):
itunes_data = {
"Name": "Party Mix",
"Playlist Persistent ID": "XYZ789",
"Playlist Items": [
{"Track ID": 10},
{"Track ID": 20},
{"Track ID": 30},
],
}
playlist = Playlist.from_itunes_dict(itunes_data)
assert playlist.name == "Party Mix"
assert playlist.persistent_id == "XYZ789"
assert playlist.playlist_type == PlaylistType.REGULAR
assert playlist.track_ids == [10, 20, 30]
assert playlist.is_system is False
def test_from_itunes_dict_system(self):
itunes_data = {
"Name": "Library",
"Master": True,
"Playlist Persistent ID": "MASTER1",
"Playlist Items": [{"Track ID": 1}],
}
playlist = Playlist.from_itunes_dict(itunes_data)
assert playlist.is_system is True
assert playlist.playlist_type == PlaylistType.SYSTEM
def test_from_itunes_dict_folder(self):
itunes_data = {
"Name": "My Folder",
"Folder": True,
"Playlist Persistent ID": "FOLDER1",
}
playlist = Playlist.from_itunes_dict(itunes_data)
assert playlist.playlist_type == PlaylistType.FOLDER
def test_from_itunes_dict_smart(self):
itunes_data = {
"Name": "Recently Added",
"Smart Info": b"...",
"Smart Criteria": b"...",
"Playlist Persistent ID": "SMART1",
}
playlist = Playlist.from_itunes_dict(itunes_data)
assert playlist.playlist_type == PlaylistType.SMART
class TestPlaylistSettings:
def test_default_settings(self):
settings = PlaylistSettings()
assert "name" in settings.visible_columns
assert settings.sort_ascending is True
def test_round_trip(self):
settings = PlaylistSettings(
visible_columns=["name", "artist"],
sort_column="artist",
sort_ascending=False,
column_widths={"name": 300},
)
d = settings.to_dict()
restored = PlaylistSettings.from_dict(d)
assert restored.visible_columns == ["name", "artist"]
assert restored.sort_column == "artist"
assert restored.sort_ascending is False
assert restored.column_widths == {"name": 300}
class TestLibrary:
def test_empty_library(self):
lib = Library()
assert len(lib.tracks) == 0
assert len(lib.playlists) == 0

107
tests/test_player.py Normal file
View File

@ -0,0 +1,107 @@
from unittest.mock import MagicMock, patch
from lintunes.models import Track
from lintunes.models.library import Library
from lintunes import player as player_module
from lintunes.player import Player
class FakeManager:
"""Minimal stand-in for LibraryManager: the Player reads `library.tracks`,
calls `record_play` on natural end-of-media, and `set_track_location` when
the user relocates a moved/renamed file."""
def __init__(self, library):
self.library = library
self.played = []
def record_play(self, track_id):
self.played.append(track_id)
def set_track_location(self, track_id, location):
self.library.tracks[track_id].location = location
def _make_player(qapp, tracks):
library = Library()
for track in tracks:
library.tracks[track.track_id] = track
# The Qt multimedia backend (QMediaPlayer/QAudioOutput/QMediaDevices)
# blocks on init in a headless environment, so stub it out — the
# missing-file path under test never reaches real playback anyway.
with patch.multiple(
player_module,
QMediaPlayer=MagicMock(),
QAudioOutput=MagicMock(),
QAudioBufferOutput=MagicMock(),
QMediaDevices=MagicMock(),
):
player = Player(FakeManager(library))
errors, missing = [], []
player.error_occurred.connect(errors.append)
player.track_missing.connect(missing.append)
return player, errors, missing
def test_missing_file_emits_track_missing_and_stops(qapp, tmp_path):
location = str(tmp_path / "not-mounted" / "song.mp3")
track = Track(track_id=1, name="Ghost Song", location=location)
player, errors, missing = _make_player(qapp, [track])
player.play_queue([1], 0)
# Routed to track_missing (so the UI can offer "Locate File…"), not the
# generic error dialog.
assert errors == []
assert missing == [track]
# Stopped, not advanced into a track; setSource was never called.
assert player.current_track is None
player._media.setSource.assert_not_called()
def test_all_missing_queue_does_not_recurse(qapp, tmp_path):
# A whole-library queue with every file gone (drive unmounted) must not
# cascade through the queue and blow the recursion limit.
tracks = [
Track(track_id=i, name=f"T{i}", location=str(tmp_path / f"{i}.mp3"))
for i in range(5000)
]
player, errors, missing = _make_player(qapp, tracks)
player.play_queue([t.track_id for t in tracks], 0)
# One alert for the track we tried to play, then a clean stop.
assert len(missing) == 1
assert player.current_track is None
def test_track_not_in_library_emits_error(qapp):
# Queue references an id that isn't in the library: nothing to locate, so
# this stays a plain error (not track_missing).
player, errors, missing = _make_player(qapp, [])
player.play_queue([99], 0)
assert missing == []
assert len(errors) == 1
assert player.current_track is None
def test_retry_current_plays_after_relocate(qapp, tmp_path):
real = tmp_path / "real.mp3"
real.write_bytes(b"audio")
track = Track(track_id=1, name="Moved Song",
location=str(tmp_path / "gone.mp3"))
player, errors, missing = _make_player(qapp, [track])
player.play_queue([1], 0)
assert missing == [track] and player.current_track is None
# User relocates the file, then the UI retries the same queue position.
player._manager.set_track_location(1, str(real))
missing.clear()
player.retry_current()
assert errors == [] and missing == []
assert player.current_track is track
player._media.setSource.assert_called_once()

47
tests/test_relocate.py Normal file
View File

@ -0,0 +1,47 @@
"""LibraryManager.set_track_location: repoint a track at a moved/renamed file.
It's a path repair, not a content edit — so it persists the new location and
notifies the UI, but must not touch date_modified or write tags.
"""
from lintunes.models import Library, Track
from lintunes.library_manager import LibraryManager
def _manager(tmp_path):
track = Track(track_id=1, name="Song",
location="/old/path/song.mp3",
date_modified="2020-01-01T00:00:00")
return LibraryManager(Library(tracks={1: track}), tmp_path), track
def test_set_track_location_repoints_and_persists(qapp, tmp_path):
manager, track = _manager(tmp_path)
updates, edits = [], []
manager.track_updated.connect(updates.append)
manager.track_fields_edited.connect(lambda tid, fields: edits.append(tid))
manager.set_track_location(1, "/new/path/from there to here.mp3")
assert track.location == "/new/path/from there to here.mp3"
assert manager._dirty_tracks # will be saved
assert updates == [1] # UI refreshes the row
assert edits == [] # not a metadata edit
assert track.date_modified == "2020-01-01T00:00:00" # untouched
def test_set_track_location_noop_when_unchanged(qapp, tmp_path):
manager, track = _manager(tmp_path)
updates = []
manager.track_updated.connect(updates.append)
manager.set_track_location(1, track.location)
assert updates == []
assert not manager._dirty_tracks
def test_set_track_location_ignores_unknown_track(qapp, tmp_path):
manager, _track = _manager(tmp_path)
manager.set_track_location(999, "/whatever.mp3") # no such track
assert not manager._dirty_tracks

105
tests/test_round3.py Normal file
View File

@ -0,0 +1,105 @@
import hashlib
import json
import pytest
from lintunes.lastfm import sign, LastFm
from lintunes.player import make_shuffle_order
from lintunes.preferences import Preferences
class TestLastFmSignature:
def test_signature_matches_hand_computed(self):
params = {
"method": "auth.getMobileSession",
"api_key": "KEY",
"username": "trav",
"password": "hunter2",
"format": "json", # must be excluded from the signature
}
expected = hashlib.md5(
("api_keyKEY"
"methodauth.getMobileSession"
"passwordhunter2"
"usernametrav"
"SECRET").encode()).hexdigest()
assert sign(params, "SECRET") == expected
def test_format_and_callback_excluded(self):
base = {"method": "x", "api_key": "k"}
with_extras = dict(base, format="json", callback="cb")
assert sign(base, "s") == sign(with_extras, "s")
class TestScrobbleQueue:
def test_queue_round_trip(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
lastfm = LastFm(prefs, tmp_path)
entry = {"artist": "A", "track": "T", "album": "L",
"timestamp": 123, "duration": 180}
lastfm._enqueue(entry)
assert lastfm._load_queue() == [entry]
lastfm._save_queue([])
assert lastfm._load_queue() == []
assert not (tmp_path / "scrobble_queue.json").exists()
def test_queue_capped(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
lastfm = LastFm(prefs, tmp_path)
for i in range(600):
lastfm._enqueue({"artist": "A", "track": str(i), "timestamp": i})
queue = lastfm._load_queue()
assert len(queue) == 500
assert queue[-1]["track"] == "599"
class TestShuffleOrder:
def test_permutation_starts_at_current(self):
order = make_shuffle_order(10, 4)
assert order[0] == 4
assert sorted(order) == list(range(10))
def test_single_track(self):
assert make_shuffle_order(1, 0) == [0]
def test_empty(self):
assert make_shuffle_order(0, 0) == []
def test_is_randomized(self):
# With 20 tracks, two consecutive shuffles colliding entirely is
# vanishingly unlikely (1/19!)
orders = {tuple(make_shuffle_order(20, 0)) for _ in range(5)}
assert len(orders) > 1
class TestPreferences:
def test_defaults(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
assert prefs.get("ui_scale") == "medium"
assert prefs.get("highlight") == "blue"
assert prefs.lastfm["scrobble_enabled"] is False
def test_round_trip(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("ui_scale", "large")
prefs.update_lastfm(username="trav", session_key="abc")
reloaded = Preferences(tmp_path)
assert reloaded.get("ui_scale") == "large"
assert reloaded.lastfm["username"] == "trav"
assert reloaded.lastfm["session_key"] == "abc"
# Unknown/missing keys fall back to defaults
assert reloaded.get("highlight") == "blue"
def test_changed_signal(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
fired = []
prefs.changed.connect(lambda: fired.append(1))
prefs.set("highlight", "seafoam")
prefs.set("highlight", "seafoam") # no-op, shouldn't fire again
assert len(fired) == 1
def test_corrupt_file_falls_back(self, qapp, tmp_path):
(tmp_path / "preferences.json").write_text("{not json")
prefs = Preferences(tmp_path)
assert prefs.get("ui_scale") == "medium"

46
tests/test_round5.py Normal file
View File

@ -0,0 +1,46 @@
from lintunes.gui.track_table import format_total_time
from lintunes.gui.library_view import _canonical_values, _artist_sort_key
class TestFormatTotalTime:
def test_minutes_seconds(self):
assert format_total_time((14 * 60 + 21) * 1000) == "14:21"
def test_hours(self):
ms = (9 * 3600 + 47 * 60 + 33) * 1000
assert format_total_time(ms) == "9:47:33"
def test_days_zero_pads_lower_units(self):
ms = (24 * 86400 + 18 * 3600 + 42 * 60 + 7) * 1000
assert format_total_time(ms) == "24:18:42:07"
def test_zero_keeps_min_sec(self):
assert format_total_time(0) == "0:00"
class TestCanonicalValues:
def test_most_tracks_spelling_wins(self):
values = ["The Beatles", "The Beatles", "the beatles"]
assert _canonical_values(values) == ["The Beatles"]
def test_tie_breaks_on_more_capitals(self):
# equal counts -> the spelling with more uppercase letters wins
assert _canonical_values(["abc", "ABC"]) == ["ABC"]
def test_distinct_names_preserved(self):
result = sorted(_canonical_values(["Beat Happening", "The Beatles"]))
assert result == ["Beat Happening", "The Beatles"]
class TestArtistSortKey:
def test_strips_leading_the(self):
assert _artist_sort_key("The Beatles") == "beatles"
def test_files_next_to_neighbor(self):
# "The Beatles" should sort adjacent to "Beat Happening"
names = ["The Beatles", "Beat Happening", "Beck"]
assert sorted(names, key=_artist_sort_key) == [
"Beat Happening", "The Beatles", "Beck"]
def test_no_article_unchanged(self):
assert _artist_sort_key("Beck") == "beck"

59
tests/test_round6.py Normal file
View File

@ -0,0 +1,59 @@
from lintunes.gui.playlist_ops import (
has_duplicates, filter_for_add, add_tracks_with_dup_check)
from lintunes.library_manager import LibraryManager
from lintunes.models import Library, Track, Playlist
class TestHasDuplicates:
def test_none_present(self):
assert has_duplicates([3, 4], [1, 2]) is False
def test_some_present(self):
assert has_duplicates([2, 3], [1, 2]) is True
def test_empty_incoming(self):
assert has_duplicates([], [1, 2]) is False
class TestFilterForAdd:
def test_add_duplicates_keeps_all_in_order(self):
assert filter_for_add([2, 3, 1], [1, 2], add_duplicates=True) == [2, 3, 1]
def test_skip_drops_existing_preserves_order(self):
assert filter_for_add([2, 3, 1], [1, 2], add_duplicates=False) == [3]
def test_skip_can_drop_everything(self):
assert filter_for_add([1, 2], [1, 2], add_duplicates=False) == []
def _manager_with(qapp, tmp_path, track_ids, playlist_track_ids=()):
library = Library()
for tid in track_ids:
library.tracks[tid] = Track(track_id=tid, name=f"t{tid}")
playlist = Playlist(name="PL", persistent_id="PID",
track_ids=list(playlist_track_ids))
library.playlists["PID"] = playlist
manager = LibraryManager(library, tmp_path)
return manager, playlist
class TestAddTracksWithDupCheck:
def test_no_duplicates_adds_all_without_prompt(self, qapp, tmp_path):
manager, playlist = _manager_with(qapp, tmp_path, [1, 2, 3], [1])
added = add_tracks_with_dup_check(manager, None, "PID", [2, 3])
assert added == [2, 3]
assert playlist.track_ids == [1, 2, 3]
def test_position_inserts_in_place(self, qapp, tmp_path):
manager, playlist = _manager_with(qapp, tmp_path, [1, 2, 3], [1, 3])
add_tracks_with_dup_check(manager, None, "PID", [2], position=1)
assert playlist.track_ids == [1, 2, 3]
def test_empty_input_is_noop(self, qapp, tmp_path):
manager, playlist = _manager_with(qapp, tmp_path, [1], [1])
assert add_tracks_with_dup_check(manager, None, "PID", []) == []
assert playlist.track_ids == [1]
def test_unknown_playlist_is_noop(self, qapp, tmp_path):
manager, _ = _manager_with(qapp, tmp_path, [1], [])
assert add_tracks_with_dup_check(manager, None, "NOPE", [1]) == []

14
tests/test_round7.py Normal file
View File

@ -0,0 +1,14 @@
from lintunes.gui import drag_ghost
class TestDragIcon:
"""The cursor-following drag indicator is loaded from a customizable PNG
that ships next to the application icon (packaging/lintunes.png)."""
def test_default_png_ships_next_to_app_icon(self):
assert drag_ghost.ICON_PATH.name == "drag-icon.png"
assert drag_ghost.ICON_PATH.exists()
assert (drag_ghost.ICON_PATH.parent / "lintunes.png").exists()
def test_default_file_is_a_png(self):
assert drag_ghost.ICON_PATH.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n"

254
tests/test_round8.py Normal file
View File

@ -0,0 +1,254 @@
"""Round 8: the four 'Open / future' features.
1. "Show in Playlist…" — LibraryManager.playlists_containing.
2. Multi-track Get Info — LibraryManager.edit_tracks_fields (one composite undo,
only genuinely-changed fields per track).
3. Library search — the _matches_search predicate behind the filter box.
4. Custom start/stop times — Get Info time parsing/formatting, library-only
persistence, and the player honoring them during playback.
"""
from unittest.mock import MagicMock, patch
from lintunes.models import Library, Track, Playlist, PlaylistType
from lintunes.library_manager import LibraryManager
from lintunes.gui.library_view import _matches_search
from lintunes.gui.info_dialog import _format_ms, _parse_time_to_ms
from lintunes import player as player_module
from lintunes.player import Player
def _track(tid, **kw):
return Track(track_id=tid, name=kw.pop("name", f"Track {tid}"), **kw)
# --------------------------------------------------------------------------
# 1. Show in Playlist
# --------------------------------------------------------------------------
class TestPlaylistsContaining:
def _manager(self, tmp_path):
tracks = {i: _track(i) for i in (1, 2, 3)}
library = Library(tracks=tracks)
# Two regular playlists, a folder, and a smart playlist.
library.playlists = {
"P1": Playlist(name="Beta", persistent_id="P1",
playlist_type=PlaylistType.REGULAR,
track_ids=[1, 2]),
"P2": Playlist(name="alpha", persistent_id="P2",
playlist_type=PlaylistType.REGULAR,
track_ids=[2]),
"F1": Playlist(name="Folder", persistent_id="F1",
playlist_type=PlaylistType.FOLDER,
track_ids=[1]),
"S1": Playlist(name="Smart", persistent_id="S1",
playlist_type=PlaylistType.SMART,
track_ids=[1]),
}
return LibraryManager(library, tmp_path)
def test_lists_only_regular_playlists_with_the_track(self, qapp, tmp_path):
manager = self._manager(tmp_path)
# Track 1 is in P1 (regular), F1 (folder), S1 (smart): only P1 qualifies.
assert manager.playlists_containing(1) == [("P1", "Beta")]
def test_sorted_by_name_casefolded(self, qapp, tmp_path):
manager = self._manager(tmp_path)
# Track 2 is in both regular playlists; "alpha" sorts before "Beta".
assert manager.playlists_containing(2) == [("P2", "alpha"), ("P1", "Beta")]
def test_track_in_no_playlist_is_empty(self, qapp, tmp_path):
manager = self._manager(tmp_path)
assert manager.playlists_containing(3) == []
# --------------------------------------------------------------------------
# 2. Multi-track Get Info
# --------------------------------------------------------------------------
class TestEditTracksFields:
def _manager(self, tmp_path):
tracks = {
1: _track(1, artist="A", album="Old", genre="Rock"),
2: _track(2, artist="B", album="New", genre="Rock"),
3: _track(3, artist="C", album="Old", genre="Jazz"),
}
# No file locations, so _write_track_tags is a no-op success.
return LibraryManager(Library(tracks=tracks), tmp_path), tracks
def test_applies_to_all_selected(self, qapp, tmp_path):
manager, tracks = self._manager(tmp_path)
manager.edit_tracks_fields([1, 2, 3], {"genre": "Pop"})
assert all(t.genre == "Pop" for t in tracks.values())
def test_one_composite_undo_reverts_everything(self, qapp, tmp_path):
manager, tracks = self._manager(tmp_path)
manager.edit_tracks_fields([1, 2, 3], {"genre": "Pop"})
# A single undo entry, not one per track.
assert manager.undo_stack.can_undo()
manager.undo_stack.undo()
assert [t.genre for t in (tracks[1], tracks[2], tracks[3])] == \
["Rock", "Rock", "Jazz"]
assert not manager.undo_stack.can_undo()
def test_only_changed_fields_per_track(self, qapp, tmp_path):
manager, tracks = self._manager(tmp_path)
edited = []
manager.track_fields_edited.connect(lambda tid, f: edited.append(tid))
# album="Old" already matches tracks 1 and 3, so only track 2 changes.
manager.edit_tracks_fields([1, 2, 3], {"album": "Old"})
assert edited == [2]
assert tracks[2].album == "Old"
def test_no_change_pushes_no_undo(self, qapp, tmp_path):
manager, tracks = self._manager(tmp_path)
manager.edit_tracks_fields([1, 2, 3], {"genre": "Rock", "album": "Old"})
# Tracks 1 & 3 already match on both touched fields; track 2 changes
# genre? no — genre Rock matches track 2 too. album New != Old -> change.
# So track 2 changes album only; an undo IS pushed.
manager.undo_stack.undo()
assert tracks[2].album == "New"
def test_truly_noop_pushes_nothing(self, qapp, tmp_path):
manager, tracks = self._manager(tmp_path)
manager.edit_tracks_fields([1], {"genre": "Rock"}) # already Rock
assert not manager.undo_stack.can_undo()
# --------------------------------------------------------------------------
# 3. Library search predicate
# --------------------------------------------------------------------------
class TestSearchMatch:
def test_substring_case_insensitive_across_fields(self):
track = _track(1, name="Yesterday", artist="The Beatles",
album="Help!", year=1965)
assert _matches_search(track, ["beat"]) # artist substring
assert _matches_search(track, ["YESTER"]) # name, case-folded
assert _matches_search(track, ["1965"]) # numeric field stringified
def test_all_tokens_must_match_and(self):
track = _track(1, name="Yesterday", artist="The Beatles", year=1965)
assert _matches_search(track, ["beatles", "1965"])
assert not _matches_search(track, ["beatles", "1999"])
def test_empty_tokens_match_everything(self):
assert _matches_search(_track(1), [])
def test_no_match(self):
track = _track(1, name="Yesterday", artist="The Beatles")
assert not _matches_search(track, ["zzdoesnotexist"])
# --------------------------------------------------------------------------
# 4. Custom start/stop times
# --------------------------------------------------------------------------
class TestTimeParsing:
def test_format_round_seconds(self):
assert _format_ms(30_000) == "0:30"
assert _format_ms(90_000) == "1:30"
assert _format_ms(0) == ""
def test_format_with_millis(self):
assert _format_ms(30_500) == "0:30.500"
def test_parse_variants(self):
assert _parse_time_to_ms("0:30") == 30_000
assert _parse_time_to_ms("1:30") == 90_000
assert _parse_time_to_ms("0:30.5") == 30_500
assert _parse_time_to_ms("") == 0
def test_parse_invalid_is_none(self):
assert _parse_time_to_ms("abc") is None
def test_round_trip(self):
for ms in (15_000, 62_250, 125_000):
assert _parse_time_to_ms(_format_ms(ms)) == ms
class TestStartStopPersistence:
def test_times_persist_to_library_json(self, qapp, tmp_path):
track = _track(1, total_time=200_000) # no location -> no file write
manager = LibraryManager(Library(tracks={1: track}), tmp_path)
manager.edit_track_fields(1, {"start_time": 30_000, "stop_time": 60_000})
assert track.start_time == 30_000 and track.stop_time == 60_000
manager.flush()
# Reload from disk and confirm they survived.
from lintunes.storage import json_storage
reloaded = json_storage.load_library(tmp_path)
assert reloaded.tracks[1].start_time == 30_000
assert reloaded.tracks[1].stop_time == 60_000
class TestPlayerStartStop:
def _player(self, qapp, tracks):
library = Library()
for t in tracks:
library.tracks[t.track_id] = t
manager = MagicMock()
manager.library = library
with patch.multiple(
player_module,
QMediaPlayer=MagicMock(),
QAudioOutput=MagicMock(),
QAudioBufferOutput=MagicMock(),
QMediaDevices=MagicMock(),
):
player = Player(manager)
return player, manager
def test_stop_time_arms_only_when_before_end(self, qapp, tmp_path):
loc = str(tmp_path / "a.mp3")
(tmp_path / "a.mp3").write_bytes(b"x")
track = _track(1, location=loc, total_time=200_000, stop_time=60_000)
player, _ = self._player(qapp, [track])
player.play_queue([1], 0)
assert player._stop_at_ms == 60_000
def test_stop_time_at_or_past_end_is_disabled(self, qapp, tmp_path):
loc = str(tmp_path / "a.mp3")
(tmp_path / "a.mp3").write_bytes(b"x")
track = _track(1, location=loc, total_time=60_000, stop_time=60_000)
player, _ = self._player(qapp, [track])
player.play_queue([1], 0)
assert player._stop_at_ms == 0
def test_position_past_stop_records_play_and_advances(self, qapp, tmp_path):
for name in ("a.mp3", "b.mp3"):
(tmp_path / name).write_bytes(b"x")
t1 = _track(1, location=str(tmp_path / "a.mp3"),
total_time=200_000, stop_time=60_000)
t2 = _track(2, location=str(tmp_path / "b.mp3"), total_time=200_000)
player, manager = self._player(qapp, [t1, t2])
finished = []
player.track_finished.connect(lambda t: finished.append(t.track_id))
player.play_queue([1, 2], 0)
player._on_position(59_999) # before stop: nothing
assert manager.record_play.call_count == 0
player._on_position(60_001) # crosses the stop time
manager.record_play.assert_called_once_with(1)
assert finished == [1]
assert player.current_track.track_id == 2 # advanced
def test_note_finished_guards_double_count(self, qapp, tmp_path):
(tmp_path / "a.mp3").write_bytes(b"x")
track = _track(1, location=str(tmp_path / "a.mp3"), total_time=200_000)
player, manager = self._player(qapp, [track])
player._current_track = track
player._counted_finish_id = None
player._note_finished(track)
player._note_finished(track) # same track again
manager.record_play.assert_called_once_with(1)
def test_loaded_media_seeks_to_pending_start(self, qapp, tmp_path):
(tmp_path / "a.mp3").write_bytes(b"x")
track = _track(1, location=str(tmp_path / "a.mp3"),
total_time=200_000, start_time=30_000)
player, _ = self._player(qapp, [track])
player.play_queue([1], 0)
assert player._pending_start_ms == 30_000
player._on_media_status(player_module.QMediaPlayer.MediaStatus.LoadedMedia)
player._media.setPosition.assert_called_with(30_000)
assert player._pending_start_ms == 0 # consumed (one-shot)

164
tests/test_round9.py Normal file
View File

@ -0,0 +1,164 @@
"""Round 9: three 'Open / future' items.
1. New playlist is selected and drops straight into inline rename
(PlaylistTree.create_playlist_interactive).
2. EQ visualizer cycles on / dim / off on click (off goes blank).
3. Album-art recovery plumbing: the .itc cache decoder (lintunes.itc).
"""
from PyQt6.QtCore import QObject, pyqtSignal
from PyQt6.QtWidgets import QAbstractItemView
from lintunes.models import Library, Track
from lintunes.library_manager import LibraryManager
from lintunes.gui.sidebar import PlaylistTree
from lintunes.gui.visualizer import VisualizerWidget, BANDS
from lintunes.itc import decode_itc
def _manager(tmp_path):
library = Library(tracks={1: Track(track_id=1, name="One")})
return LibraryManager(library, tmp_path)
# --------------------------------------------------------------------------
# 1. New playlist: instant select + live inline rename
# --------------------------------------------------------------------------
class TestCreatePlaylistInteractive:
def test_new_playlist_is_selected_and_editing(self, qapp, tmp_path):
manager = _manager(tmp_path)
tree = PlaylistTree(manager)
tree.create_playlist_interactive()
playlists = list(manager.library.playlists.values())
assert len(playlists) == 1
new = playlists[0]
assert new.name == "untitled playlist"
# Selected...
assert tree.current_playlist_id() == new.persistent_id
# ...and the inline editor is open so typing renames it immediately.
assert tree.state() == QAbstractItemView.State.EditingState
def test_committing_the_edit_renames_via_manager(self, qapp, tmp_path):
manager = _manager(tmp_path)
tree = PlaylistTree(manager)
tree.create_playlist_interactive()
pid = tree.current_playlist_id()
# Simulate the user typing a name and the editor committing: the
# itemChanged -> _on_item_renamed -> rename_playlist wiring fires.
item = tree._find_item(pid)
item.setText(0, "Road Trip")
assert manager.library.playlists[pid].name == "Road Trip"
# --------------------------------------------------------------------------
# 2. EQ visualizer blank-when-off
# --------------------------------------------------------------------------
class _FakePlayer(QObject):
audio_buffer = pyqtSignal(object)
playing_changed = pyqtSignal(bool)
track_changed = pyqtSignal(object)
def __init__(self):
super().__init__()
self._playing = False
def is_playing(self):
return self._playing
class TestVisualizerToggle:
def test_click_cycles_on_dim_off(self, qapp):
from lintunes.gui.visualizer import MODE_ON, MODE_DIM, MODE_OFF
player = _FakePlayer()
player._playing = True
vis = VisualizerWidget(player)
assert vis._mode == MODE_ON
vis.mousePressEvent(None) # on -> dim
assert vis._mode == MODE_DIM
vis.mousePressEvent(None) # dim -> off
assert vis._mode == MODE_OFF
vis.mousePressEvent(None) # off -> on (wraps)
assert vis._mode == MODE_ON
def test_off_goes_blank_and_stops(self, qapp):
from lintunes.gui.visualizer import MODE_OFF
player = _FakePlayer()
player._playing = True
vis = VisualizerWidget(player)
vis._bars = [7] * BANDS # pretend mid-animation
vis.mousePressEvent(None) # on -> dim
vis.mousePressEvent(None) # dim -> off
assert vis._mode == MODE_OFF
assert vis._bars == [0] * BANDS # blank, not the held frame
assert not vis._levels.any()
assert not vis._timer.isActive() # stopped
def test_dim_keeps_animating_when_playing(self, qapp):
from lintunes.gui.visualizer import MODE_DIM
player = _FakePlayer()
player._playing = True
vis = VisualizerWidget(player)
vis.mousePressEvent(None) # on -> dim
assert vis._mode == MODE_DIM
assert vis._timer.isActive() # dim animates while playing
def test_cycling_back_on_resumes_when_playing(self, qapp):
from lintunes.gui.visualizer import MODE_ON
player = _FakePlayer()
player._playing = True
vis = VisualizerWidget(player)
vis.mousePressEvent(None) # on -> dim
vis.mousePressEvent(None) # dim -> off
vis.mousePressEvent(None) # off -> on again
assert vis._mode == MODE_ON
assert vis._timer.isActive()
# --------------------------------------------------------------------------
# 3. .itc artwork-cache decoder
# --------------------------------------------------------------------------
def _itc_header():
# A minimal iTunes .itc-style header preamble; decode_itc only cares that
# the real image magic appears after it.
return bytes([0, 0, 1, 0x1c]) + b"itch" + b"\x00" * 8 + b"artw" + \
b"\x00" * 8 + b"item" + b"\x00" * 8
class TestItcDecoder:
def test_extracts_jpeg(self):
image = b"\xff\xd8\xff\xe0" + b"JFIFpayload" + b"\xff\xd9"
blob = _itc_header() + image + b"\x00\x00trailing"
out = decode_itc(blob)
assert out is not None
data, mime = out
assert mime == "image/jpeg"
assert data == image # trimmed to EOI, trailing dropped
def test_extracts_png(self):
sig = b"\x89PNG\r\n\x1a\n"
image = sig + b"IHDRfakechunks" + b"IEND\xae\x42\x60\x82"
blob = _itc_header() + image + b"junk"
out = decode_itc(blob)
assert out is not None
data, mime = out
assert mime == "image/png"
assert data == image
def test_raw_bitmap_is_unsupported(self):
# ARGb/PNGf raw payloads have no JPEG/PNG magic -> can't recover.
blob = _itc_header() + b"ARGb" + bytes(range(64))
assert decode_itc(blob) is None

60
tests/test_tagging.py Normal file
View File

@ -0,0 +1,60 @@
import pytest
from lintunes import tagging
FIELDS = {
"name": "Test Title",
"artist": "Test Artist",
"album_artist": "Test Album Artist",
"album": "Test Album",
"genre": "Electronic",
"composer": "Test Composer",
"comments": "A comment",
"grouping": "Test Group",
"year": 2021,
"track_number": 3,
"track_count": 12,
"disc_number": 1,
"disc_count": 2,
"compilation": True,
"bpm": 120,
}
@pytest.mark.parametrize("fixture", ["mp3_file", "m4a_file", "flac_file"])
def test_write_read_round_trip(fixture, request):
path = request.getfixturevalue(fixture)
tagging.write_tags(path, FIELDS)
read = tagging.read_tags(path)
for key in ("name", "artist", "album_artist", "album", "genre",
"composer", "comments"):
assert read.get(key) == FIELDS[key], key
assert read.get("year") == 2021
assert read.get("track_number") == 3
assert read.get("track_count") == 12
assert read.get("disc_number") == 1
assert read.get("total_time", 0) > 500 # ~1s of audio
assert read.get("size", 0) > 0
def test_read_tags_reports_audio_properties(mp3_file):
read = tagging.read_tags(mp3_file)
assert read.get("kind") == "MPEG audio file"
assert read.get("sample_rate", 0) > 0
def test_no_artwork_in_generated_file(mp3_file):
assert tagging.read_embedded_artwork(mp3_file) is None
@pytest.mark.parametrize("fixture", ["mp3_file", "m4a_file", "flac_file"])
def test_artwork_write_read_round_trip(fixture, request, jpeg_bytes):
path = request.getfixturevalue(fixture)
assert tagging.read_embedded_artwork(path) is None
tagging.write_artwork(path, jpeg_bytes, "image/jpeg")
assert tagging.read_embedded_artwork(path) == jpeg_bytes
# Replacing art works too (no duplicate covers accumulate)
tagging.write_artwork(path, jpeg_bytes + b"\x00", "image/jpeg")
assert tagging.read_embedded_artwork(path) == jpeg_bytes + b"\x00"

49
tests/test_tap_tempo.py Normal file
View File

@ -0,0 +1,49 @@
from lintunes.tap_tempo import TapTempo
def test_steady_taps_120_bpm():
tempo = TapTempo()
bpm = None
for i in range(5):
bpm = tempo.tap(10.0 + i * 0.5) # 0.5s intervals = 120 bpm
assert bpm == 120
def test_first_tap_gives_no_bpm():
tempo = TapTempo()
assert tempo.tap(1.0) is None
assert tempo.tap_count == 1
def test_long_gap_resets_series():
tempo = TapTempo()
tempo.tap(0.0)
tempo.tap(0.5)
assert tempo.bpm() == 120
# 5 seconds later: new series, old taps discarded
assert tempo.tap(5.5) is None
assert tempo.tap_count == 1
assert tempo.tap(6.1) == 100 # 0.6s interval
def test_window_uses_recent_taps_only():
tempo = TapTempo()
# 4 slow taps (1s = 60bpm) then 8 fast taps (0.25s = 240bpm);
# only the last 8 taps count, so the slow ones age out entirely
t = 0.0
for _ in range(4):
tempo.tap(t)
t += 1.0
for _ in range(8):
tempo.tap(t)
t += 0.25
assert tempo.bpm() == 240
def test_reset():
tempo = TapTempo()
tempo.tap(0.0)
tempo.tap(0.5)
tempo.reset()
assert tempo.bpm() is None
assert tempo.tap_count == 0

56
tests/test_track_edit.py Normal file
View File

@ -0,0 +1,56 @@
"""Regression tests for live browser refresh after a Get-Info edit.
The bug: renaming a track's artist left the column browser (and its filter)
pointing at the old name, so the renamed artist didn't re-sort and clicking it
returned no tracks. The fix routes a new `track_fields_edited(track_id, fields)`
signal to the library view, which rebuilds the browser only when a field it
groups by changed — and leaves play-count/skip bumps alone.
"""
from lintunes.models import Library, Track
from lintunes.library_manager import LibraryManager
from lintunes.gui.library_view import _BROWSER_FIELDS
def _manager(tmp_path):
track = Track(track_id=1, name="Song", artist="Aardvark",
album="Debut", genre="Rock")
library = Library(tracks={1: track})
return LibraryManager(library, tmp_path), track
def test_edit_emits_changed_fields(qapp, tmp_path):
manager, _track = _manager(tmp_path)
edits, updates = [], []
manager.track_fields_edited.connect(lambda tid, fields: edits.append((tid, fields)))
manager.track_updated.connect(updates.append)
manager.update_track_fields(1, {"artist": "Zz Top"})
assert edits == [(1, ["artist"])]
assert updates == [1]
assert _BROWSER_FIELDS.intersection(edits[0][1]) # would refresh the browser
def test_no_op_edit_is_silent(qapp, tmp_path):
manager, _track = _manager(tmp_path)
edits = []
manager.track_fields_edited.connect(lambda tid, fields: edits.append((tid, fields)))
manager.update_track_fields(1, {"artist": "Aardvark"}) # unchanged value
assert edits == []
def test_play_count_does_not_reorganize(qapp, tmp_path):
"""record_play bumps play_count via track_updated but must NOT emit
track_fields_edited, so a finished play never rebuilds the browser."""
manager, _track = _manager(tmp_path)
edits, updates = [], []
manager.track_fields_edited.connect(lambda tid, fields: edits.append((tid, fields)))
manager.track_updated.connect(updates.append)
manager.record_play(1)
assert edits == []
assert updates == [1]

178
tests/test_undo.py Normal file
View File

@ -0,0 +1,178 @@
"""Undo/redo round-trips for LibraryManager operations (no GUI)."""
from lintunes.library_manager import LibraryManager
from lintunes.models import Library, Track, Playlist, PlaylistType
from lintunes.undo import UndoStack, Command
def _manager(tmp_path, track_ids=(), playlists=None):
library = Library()
for tid in track_ids:
library.tracks[tid] = Track(track_id=tid, name=f"t{tid}")
for pl in (playlists or []):
library.playlists[pl.persistent_id] = pl
return LibraryManager(library, tmp_path)
def _playlist(pid="PID", track_ids=(), parent="", folder=False):
return Playlist(
name=pid, persistent_id=pid, parent_persistent_id=parent,
playlist_type=PlaylistType.FOLDER if folder else PlaylistType.REGULAR,
track_ids=list(track_ids))
# ---- the stack itself ----
class TestUndoStack:
def test_caps_at_maxlen(self):
stack = UndoStack(maxlen=3)
state = []
for i in range(5):
stack.push(Command(str(i), undo=lambda i=i: state.append(-i),
redo=lambda: None))
# Only the last 3 survive; undo them all and the 4th from last is gone.
for _ in range(5):
stack.undo()
assert state == [-4, -3, -2] # commands 0,1 were dropped
def test_redo_branch_cleared_on_new_push(self):
stack = UndoStack()
stack.push(Command("a", undo=lambda: None, redo=lambda: None))
stack.undo()
assert stack.can_redo()
stack.push(Command("b", undo=lambda: None, redo=lambda: None))
assert not stack.can_redo()
def test_labels(self):
stack = UndoStack()
stack.push(Command("Add to Playlist", undo=lambda: None, redo=lambda: None))
assert stack.undo_label() == "Add to Playlist"
stack.undo()
assert stack.redo_label() == "Add to Playlist"
assert stack.undo_label() == ""
# ---- playlist contents ----
class TestPlaylistContentUndo:
def test_add_tracks(self, qapp, tmp_path):
pl = _playlist(track_ids=[1])
m = _manager(tmp_path, [1, 2, 3], [pl])
m.add_tracks_to_playlist("PID", [2, 3])
assert pl.track_ids == [1, 2, 3]
m.undo_stack.undo()
assert pl.track_ids == [1]
m.undo_stack.redo()
assert pl.track_ids == [1, 2, 3]
def test_remove_tracks(self, qapp, tmp_path):
pl = _playlist(track_ids=[1, 2, 3])
m = _manager(tmp_path, [1, 2, 3], [pl])
m.remove_tracks_from_playlist("PID", [0, 2])
assert pl.track_ids == [2]
m.undo_stack.undo()
assert pl.track_ids == [1, 2, 3]
m.undo_stack.redo()
assert pl.track_ids == [2]
def test_move_tracks(self, qapp, tmp_path):
pl = _playlist(track_ids=[1, 2, 3, 4])
m = _manager(tmp_path, [1, 2, 3, 4], [pl])
m.move_tracks_in_playlist("PID", [0], 3)
moved = list(pl.track_ids)
assert moved != [1, 2, 3, 4]
m.undo_stack.undo()
assert pl.track_ids == [1, 2, 3, 4]
m.undo_stack.redo()
assert pl.track_ids == moved
def test_noop_add_does_not_push(self, qapp, tmp_path):
pl = _playlist(track_ids=[1])
m = _manager(tmp_path, [1], [pl])
m.add_tracks_to_playlist("PID", []) # nothing valid
assert not m.undo_stack.can_undo()
# ---- playlist structure ----
class TestPlaylistStructureUndo:
def test_create_playlist(self, qapp, tmp_path):
m = _manager(tmp_path)
pl = m.create_playlist("New")
assert pl.persistent_id in m.library.playlists
m.undo_stack.undo()
assert pl.persistent_id not in m.library.playlists
m.undo_stack.redo()
assert pl.persistent_id in m.library.playlists
def test_rename_playlist(self, qapp, tmp_path):
pl = _playlist()
m = _manager(tmp_path, playlists=[pl])
m.rename_playlist("PID", "Renamed")
assert pl.name == "Renamed"
m.undo_stack.undo()
assert pl.name == "PID"
m.undo_stack.redo()
assert pl.name == "Renamed"
def test_move_playlist(self, qapp, tmp_path):
folder = _playlist("FID", folder=True)
pl = _playlist("PID")
m = _manager(tmp_path, playlists=[folder, pl])
m.move_playlist("PID", "FID")
assert pl.parent_persistent_id == "FID"
m.undo_stack.undo()
assert pl.parent_persistent_id == ""
m.undo_stack.redo()
assert pl.parent_persistent_id == "FID"
def test_delete_playlist_restores_contents(self, qapp, tmp_path):
pl = _playlist(track_ids=[1, 2])
m = _manager(tmp_path, [1, 2], [pl])
m.delete_playlist("PID")
assert "PID" not in m.library.playlists
m.undo_stack.undo()
assert "PID" in m.library.playlists
assert m.library.playlists["PID"].track_ids == [1, 2]
def test_delete_folder_restores_descendants(self, qapp, tmp_path):
folder = _playlist("FID", folder=True)
child = _playlist("CID", track_ids=[1], parent="FID")
m = _manager(tmp_path, [1], [folder, child])
m.delete_playlist("FID")
assert "FID" not in m.library.playlists
assert "CID" not in m.library.playlists
m.undo_stack.undo()
assert "FID" in m.library.playlists
assert "CID" in m.library.playlists
assert m.library.playlists["CID"].track_ids == [1]
# ---- metadata ----
class TestMetadataUndo:
def test_edit_fields_no_location(self, qapp, tmp_path):
# No file location -> tag write is skipped but library/undo still work.
m = _manager(tmp_path, [1])
m.edit_track_fields(1, {"name": "New Name", "artist": "New Artist"})
track = m.library.tracks[1]
assert track.name == "New Name" and track.artist == "New Artist"
m.undo_stack.undo()
assert track.name == "t1" and (track.artist or "") == ""
m.undo_stack.redo()
assert track.name == "New Name"
def test_edit_unchanged_does_not_push(self, qapp, tmp_path):
m = _manager(tmp_path, [1])
m.edit_track_fields(1, {"name": "t1"}) # same value
assert not m.undo_stack.can_undo()
def test_edit_writes_file_tags_and_reverts(self, qapp, tmp_path, mp3_file):
from lintunes import tagging
track = Track(track_id=1, name="orig", location=str(mp3_file))
library = Library()
library.tracks[1] = track
m = LibraryManager(library, tmp_path)
m.edit_track_fields(1, {"name": "edited", "artist": "Somebody"})
assert tagging.read_tags(str(mp3_file))["name"] == "edited"
m.undo_stack.undo()
assert tagging.read_tags(str(mp3_file))["name"] == "orig"