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:
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal 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
92
README.md
Normal 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
59
TASKS.md
Normal 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
0
lintunes/__init__.py
Normal file
26
lintunes/config.py
Normal file
26
lintunes/config.py
Normal 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
0
lintunes/gui/__init__.py
Normal file
36
lintunes/gui/art_window.py
Normal file
36
lintunes/gui/art_window.py
Normal 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
137
lintunes/gui/drag_ghost.py
Normal 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
78
lintunes/gui/icons.py
Normal 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
456
lintunes/gui/info_dialog.py
Normal file
@ -0,0 +1,456 @@
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QDialogButtonBox, QFormLayout, QLineEdit, QSpinBox, QCheckBox,
|
||||
QPlainTextEdit, QHBoxLayout, QLabel, QVBoxLayout, QPushButton, QMessageBox,
|
||||
QApplication, QMenu, QFileDialog, QWidget,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QBuffer, QIODevice, pyqtSignal
|
||||
from PyQt6.QtGui import QPixmap, QKeySequence
|
||||
|
||||
from lintunes import tagging
|
||||
|
||||
|
||||
_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
|
||||
|
||||
|
||||
class ArtSquare(QLabel):
|
||||
"""Clickable cover-art square: click to focus, then paste (Ctrl+V) or
|
||||
drop an image file to stage new artwork."""
|
||||
|
||||
art_staged = pyqtSignal(bytes, str) # image bytes, mime
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setFixedSize(120, 120)
|
||||
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.ClickFocus)
|
||||
self.setAcceptDrops(True)
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(self._show_menu)
|
||||
self.set_artwork(None)
|
||||
|
||||
def set_artwork(self, image_bytes: bytes | None):
|
||||
if image_bytes:
|
||||
pixmap = QPixmap()
|
||||
if pixmap.loadFromData(image_bytes):
|
||||
self.setPixmap(pixmap.scaled(
|
||||
self.size(), Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation))
|
||||
self.setStyleSheet(
|
||||
"ArtSquare { border: 1px solid palette(mid); }"
|
||||
"ArtSquare:focus { border: 2px solid palette(highlight); }")
|
||||
return
|
||||
self.setPixmap(QPixmap())
|
||||
self.setText("no artwork\n\nclick, then paste\nan image")
|
||||
self.setStyleSheet(
|
||||
"ArtSquare { border: 2px dashed palette(mid); color: palette(mid); }"
|
||||
"ArtSquare:focus { border: 2px dashed palette(highlight); }")
|
||||
|
||||
# ---- staging from clipboard / files ----
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
if event.matches(QKeySequence.StandardKey.Paste):
|
||||
self.paste_from_clipboard()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def paste_from_clipboard(self):
|
||||
mime = QApplication.clipboard().mimeData()
|
||||
if mime is None:
|
||||
return
|
||||
# Prefer raw image file bytes (keeps original compression)
|
||||
for fmt in ("image/jpeg", "image/png"):
|
||||
if mime.hasFormat(fmt):
|
||||
self._stage(bytes(mime.data(fmt)), fmt)
|
||||
return
|
||||
if mime.hasImage():
|
||||
image = QApplication.clipboard().image()
|
||||
if not image.isNull():
|
||||
buffer = QBuffer()
|
||||
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
image.save(buffer, "JPEG", quality=92)
|
||||
self._stage(bytes(buffer.data()), "image/jpeg")
|
||||
return
|
||||
if mime.hasUrls():
|
||||
for url in mime.urls():
|
||||
if url.isLocalFile() and self._stage_file(Path(url.toLocalFile())):
|
||||
return
|
||||
|
||||
def _stage_file(self, path: Path) -> bool:
|
||||
if path.suffix.lower() not in _IMAGE_EXTENSIONS or not path.is_file():
|
||||
return False
|
||||
mime = "image/png" if path.suffix.lower() == ".png" else "image/jpeg"
|
||||
if path.suffix.lower() not in (".jpg", ".jpeg", ".png"):
|
||||
# Recode exotic formats to JPEG so all tag formats accept them
|
||||
pixmap = QPixmap(str(path))
|
||||
if pixmap.isNull():
|
||||
return False
|
||||
buffer = QBuffer()
|
||||
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
pixmap.save(buffer, "JPEG", quality=92)
|
||||
self._stage(bytes(buffer.data()), "image/jpeg")
|
||||
return True
|
||||
self._stage(path.read_bytes(), mime)
|
||||
return True
|
||||
|
||||
def _stage(self, image_bytes: bytes, mime: str):
|
||||
self.set_artwork(image_bytes)
|
||||
self.art_staged.emit(image_bytes, mime)
|
||||
|
||||
def _show_menu(self, pos):
|
||||
menu = QMenu(self)
|
||||
paste = menu.addAction("Paste Image\tCtrl+V")
|
||||
choose = menu.addAction("Choose File…")
|
||||
chosen = menu.exec(self.mapToGlobal(pos))
|
||||
if chosen is paste:
|
||||
self.paste_from_clipboard()
|
||||
elif chosen is choose:
|
||||
file_name, _filter = QFileDialog.getOpenFileName(
|
||||
self, "Choose Artwork", "",
|
||||
"Images (*.jpg *.jpeg *.png *.gif *.bmp *.webp)")
|
||||
if file_name:
|
||||
self._stage_file(Path(file_name))
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
if event.mimeData().hasUrls() or event.mimeData().hasImage():
|
||||
event.acceptProposedAction()
|
||||
|
||||
def dropEvent(self, event):
|
||||
for url in event.mimeData().urls():
|
||||
if url.isLocalFile() and self._stage_file(Path(url.toLocalFile())):
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
|
||||
|
||||
_TEXT_FIELDS = [
|
||||
("name", "Name"),
|
||||
("artist", "Artist"),
|
||||
("album_artist", "Album Artist"),
|
||||
("album", "Album"),
|
||||
("genre", "Genre"),
|
||||
("composer", "Composer"),
|
||||
("grouping", "Grouping"),
|
||||
]
|
||||
|
||||
_NUMBER_FIELDS = [
|
||||
("year", "Year", 9999),
|
||||
("track_number", "Track Number", 999),
|
||||
("track_count", "of (tracks)", 999),
|
||||
("disc_number", "Disc Number", 99),
|
||||
("disc_count", "of (discs)", 99),
|
||||
("bpm", "BPM", 999),
|
||||
]
|
||||
|
||||
# Distinguishes "every selected track shares this value" from "the tracks
|
||||
# disagree" when editing several at once.
|
||||
_MIXED = object()
|
||||
|
||||
|
||||
def _format_ms(ms: int) -> str:
|
||||
"""Render milliseconds as iTunes-style m:ss(.mmm) (millis only when set)."""
|
||||
if ms <= 0:
|
||||
return ""
|
||||
mins, rem = divmod(ms, 60_000)
|
||||
secs = rem / 1000
|
||||
if rem % 1000 == 0:
|
||||
return f"{mins}:{int(secs):02d}"
|
||||
return f"{mins}:{secs:06.3f}"
|
||||
|
||||
|
||||
def _parse_time_to_ms(text: str):
|
||||
"""Parse m:ss(.mmm) / h:mm:ss / plain seconds to ms; None if unparseable."""
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return 0
|
||||
try:
|
||||
parts = [float(p) for p in text.split(":")]
|
||||
except ValueError:
|
||||
return None
|
||||
seconds = 0.0
|
||||
for part in parts:
|
||||
seconds = seconds * 60 + part
|
||||
return int(round(seconds * 1000))
|
||||
|
||||
|
||||
class _TimeField(QLineEdit):
|
||||
"""Small text field that edits a duration in milliseconds as m:ss.mmm."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setFixedWidth(90)
|
||||
self.setPlaceholderText("0:00")
|
||||
|
||||
def set_ms(self, ms: int):
|
||||
self.setText(_format_ms(ms))
|
||||
|
||||
def ms(self) -> int:
|
||||
value = _parse_time_to_ms(self.text())
|
||||
return value if value is not None else 0
|
||||
|
||||
|
||||
class InfoDialog(QDialog):
|
||||
"""iTunes-style Get Info: edits library fields and the file's real tags.
|
||||
|
||||
Single track: Previous/Next walk a multi-selection one at a time, and the
|
||||
Options block exposes custom start/stop times. Multiple tracks: a combined
|
||||
editor where fields shared by every selection are editable and applied to
|
||||
all, while fields that differ show "Mixed" and are only written if touched.
|
||||
"""
|
||||
|
||||
def __init__(self, manager, track_ids: list[int], parent=None):
|
||||
super().__init__(parent)
|
||||
self._manager = manager
|
||||
self._track_ids = [tid for tid in track_ids
|
||||
if tid in manager.library.tracks]
|
||||
self._index = 0
|
||||
self._multi = len(self._track_ids) > 1
|
||||
# In multi mode, only fields the user actually edits get written to
|
||||
# every track (so an untouched "Mixed" field never overwrites anything).
|
||||
self._touched: set[str] = set()
|
||||
self._loading = False
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
top = QHBoxLayout()
|
||||
layout.addLayout(top)
|
||||
form = QFormLayout()
|
||||
top.addLayout(form, stretch=1)
|
||||
|
||||
art_col = QVBoxLayout()
|
||||
self._art_square = ArtSquare()
|
||||
self._art_square.art_staged.connect(self._on_art_staged)
|
||||
art_col.addWidget(self._art_square)
|
||||
art_col.addStretch()
|
||||
top.addLayout(art_col)
|
||||
self._staged_art: tuple[bytes, str] | None = None
|
||||
|
||||
self._editors = {}
|
||||
for field, label in _TEXT_FIELDS:
|
||||
editor = QLineEdit()
|
||||
# textEdited fires only on user input, so loading values never
|
||||
# marks a field touched.
|
||||
editor.textEdited.connect(lambda _t, f=field: self._mark_touched(f))
|
||||
self._editors[field] = editor
|
||||
form.addRow(label + ":", editor)
|
||||
|
||||
for field, label, maximum in _NUMBER_FIELDS:
|
||||
editor = QSpinBox()
|
||||
editor.setRange(0, maximum)
|
||||
editor.setSpecialValueText(" ")
|
||||
editor.valueChanged.connect(lambda _v, f=field: self._mark_touched(f))
|
||||
self._editors[field] = editor
|
||||
form.addRow(label + ":", editor)
|
||||
|
||||
self._compilation = QCheckBox("Album is a compilation of songs by various artists")
|
||||
self._compilation.clicked.connect(lambda: self._mark_touched("compilation"))
|
||||
form.addRow("", self._compilation)
|
||||
|
||||
self._comments = QPlainTextEdit()
|
||||
self._comments.setFixedHeight(60)
|
||||
self._comments.textChanged.connect(lambda: self._mark_touched("comments"))
|
||||
form.addRow("Comments:", self._comments)
|
||||
|
||||
# Custom start/stop times (single-track only; one absolute ms value
|
||||
# across different-length tracks isn't meaningful).
|
||||
self._start_row, self._start_check, self._start_time = \
|
||||
self._make_time_row("Start time")
|
||||
self._stop_row, self._stop_check, self._stop_time = \
|
||||
self._make_time_row("Stop time")
|
||||
form.addRow("", self._start_row)
|
||||
form.addRow("", self._stop_row)
|
||||
|
||||
self._file_label = QLabel()
|
||||
self._file_label.setStyleSheet("color: gray; font-size: 10px;")
|
||||
self._file_label.setWordWrap(True)
|
||||
layout.addWidget(self._file_label)
|
||||
|
||||
nav_row = QHBoxLayout()
|
||||
self._prev_btn = QPushButton("Previous")
|
||||
self._next_btn = QPushButton("Next")
|
||||
nav_row.addWidget(self._prev_btn)
|
||||
nav_row.addWidget(self._next_btn)
|
||||
nav_row.addStretch()
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok
|
||||
| QDialogButtonBox.StandardButton.Cancel)
|
||||
nav_row.addWidget(buttons)
|
||||
layout.addLayout(nav_row)
|
||||
|
||||
self._prev_btn.clicked.connect(lambda: self._navigate(-1))
|
||||
self._next_btn.clicked.connect(lambda: self._navigate(1))
|
||||
buttons.accepted.connect(self._on_ok)
|
||||
buttons.rejected.connect(self.reject)
|
||||
|
||||
if self._multi:
|
||||
# Batch editing: no single file, no per-track art/times navigation.
|
||||
self._art_square.setVisible(False)
|
||||
self._start_row.setVisible(False)
|
||||
self._stop_row.setVisible(False)
|
||||
self._file_label.setVisible(False)
|
||||
self._prev_btn.setVisible(False)
|
||||
self._next_btn.setVisible(False)
|
||||
self._load_multi()
|
||||
else:
|
||||
self._load_current()
|
||||
|
||||
def _make_time_row(self, title: str):
|
||||
row = QWidget()
|
||||
h = QHBoxLayout(row)
|
||||
h.setContentsMargins(0, 0, 0, 0)
|
||||
check = QCheckBox(title)
|
||||
field = _TimeField()
|
||||
field.setEnabled(False)
|
||||
check.toggled.connect(field.setEnabled)
|
||||
h.addWidget(check)
|
||||
h.addWidget(field)
|
||||
h.addStretch()
|
||||
return row, check, field
|
||||
|
||||
def _mark_touched(self, field: str):
|
||||
if not self._loading:
|
||||
self._touched.add(field)
|
||||
|
||||
def _current_track(self):
|
||||
return self._manager.library.tracks[self._track_ids[self._index]]
|
||||
|
||||
def _common_value(self, field: str):
|
||||
"""The value shared by every selected track, else _MIXED."""
|
||||
tracks = self._manager.library.tracks
|
||||
values = [getattr(tracks[tid], field) for tid in self._track_ids]
|
||||
first = values[0]
|
||||
return first if all(v == first for v in values) else _MIXED
|
||||
|
||||
def _on_art_staged(self, image_bytes: bytes, mime: str):
|
||||
self._staged_art = (bytes(image_bytes), mime)
|
||||
|
||||
def _load_current(self):
|
||||
track = self._current_track()
|
||||
self._loading = True
|
||||
self.setWindowTitle(f"{track.name} — Info")
|
||||
self._staged_art = None
|
||||
existing_art = (tagging.read_embedded_artwork(track.location)
|
||||
if track.location else None)
|
||||
self._art_square.set_artwork(existing_art)
|
||||
for field, _label in _TEXT_FIELDS:
|
||||
self._editors[field].setText(getattr(track, field) or "")
|
||||
for field, _label, _max in _NUMBER_FIELDS:
|
||||
self._editors[field].setValue(getattr(track, field) or 0)
|
||||
self._compilation.setChecked(track.compilation)
|
||||
self._comments.setPlainText(track.comments or "")
|
||||
self._start_check.setChecked(track.start_time > 0)
|
||||
self._start_time.setEnabled(track.start_time > 0)
|
||||
self._start_time.set_ms(track.start_time)
|
||||
self._stop_check.setChecked(track.stop_time > 0)
|
||||
self._stop_time.setEnabled(track.stop_time > 0)
|
||||
self._stop_time.set_ms(track.stop_time)
|
||||
self._file_label.setText(track.location or "")
|
||||
self._prev_btn.setEnabled(self._index > 0)
|
||||
self._next_btn.setEnabled(self._index < len(self._track_ids) - 1)
|
||||
self._loading = False
|
||||
|
||||
def _load_multi(self):
|
||||
self._loading = True
|
||||
self.setWindowTitle(f"{len(self._track_ids)} Items — Info")
|
||||
for field, _label in _TEXT_FIELDS:
|
||||
common = self._common_value(field)
|
||||
editor = self._editors[field]
|
||||
if common is _MIXED:
|
||||
editor.clear()
|
||||
editor.setPlaceholderText("Mixed")
|
||||
else:
|
||||
editor.setText(common or "")
|
||||
for field, _label, _max in _NUMBER_FIELDS:
|
||||
common = self._common_value(field)
|
||||
editor = self._editors[field]
|
||||
if common is _MIXED:
|
||||
editor.setSpecialValueText("Mixed")
|
||||
editor.setValue(0)
|
||||
else:
|
||||
editor.setValue(common or 0)
|
||||
comp = self._common_value("compilation")
|
||||
self._compilation.setChecked(False if comp is _MIXED else bool(comp))
|
||||
comments = self._common_value("comments")
|
||||
if comments is _MIXED:
|
||||
self._comments.setPlaceholderText("Mixed")
|
||||
self._comments.setPlainText("")
|
||||
else:
|
||||
self._comments.setPlainText(comments or "")
|
||||
self._loading = False
|
||||
|
||||
def _collect_fields(self) -> dict:
|
||||
fields = {}
|
||||
for field, _label in _TEXT_FIELDS:
|
||||
fields[field] = self._editors[field].text().strip()
|
||||
for field, _label, _max in _NUMBER_FIELDS:
|
||||
fields[field] = self._editors[field].value()
|
||||
fields["compilation"] = self._compilation.isChecked()
|
||||
fields["comments"] = self._comments.toPlainText().strip()
|
||||
fields["start_time"] = (self._start_time.ms()
|
||||
if self._start_check.isChecked() else 0)
|
||||
fields["stop_time"] = (self._stop_time.ms()
|
||||
if self._stop_check.isChecked() else 0)
|
||||
return fields
|
||||
|
||||
def _validate_times(self, track) -> str | None:
|
||||
start = self._start_time.ms() if self._start_check.isChecked() else 0
|
||||
stop = self._stop_time.ms() if self._stop_check.isChecked() else 0
|
||||
if start and track.total_time and start >= track.total_time:
|
||||
return "The start time is past the end of the track."
|
||||
if start and stop and stop <= start:
|
||||
return "The stop time must be after the start time."
|
||||
return None
|
||||
|
||||
def _save_current(self) -> bool:
|
||||
track = self._current_track()
|
||||
error = self._validate_times(track)
|
||||
if error:
|
||||
QMessageBox.warning(self, "Invalid times", error)
|
||||
return False
|
||||
fields = self._collect_fields()
|
||||
fields_changed = any(getattr(track, k) != v for k, v in fields.items())
|
||||
if not fields_changed and self._staged_art is None:
|
||||
return True
|
||||
if track.location:
|
||||
try:
|
||||
if self._staged_art is not None:
|
||||
tagging.write_artwork(track.location, *self._staged_art)
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "Tag write failed",
|
||||
f"Could not write tags to file:\n{e}")
|
||||
return False
|
||||
if self._staged_art is not None:
|
||||
# Embedding art changed the file; keep the library's size honest
|
||||
# (and trigger track_updated so the transport art refreshes)
|
||||
fields["size"] = Path(track.location).stat().st_size
|
||||
# Field edits (incl. the post-art size bump) go through the manager,
|
||||
# which writes the file tags and records the change for undo. Artwork
|
||||
# itself is written above and is intentionally not undoable.
|
||||
self._manager.edit_track_fields(track.track_id, fields)
|
||||
self._staged_art = None
|
||||
return True
|
||||
|
||||
def _save_multi(self) -> bool:
|
||||
fields = {}
|
||||
for field, _label in _TEXT_FIELDS:
|
||||
if field in self._touched:
|
||||
fields[field] = self._editors[field].text().strip()
|
||||
for field, _label, _max in _NUMBER_FIELDS:
|
||||
if field in self._touched:
|
||||
fields[field] = self._editors[field].value()
|
||||
if "compilation" in self._touched:
|
||||
fields["compilation"] = self._compilation.isChecked()
|
||||
if "comments" in self._touched:
|
||||
fields["comments"] = self._comments.toPlainText().strip()
|
||||
if fields:
|
||||
self._manager.edit_tracks_fields(self._track_ids, fields)
|
||||
return True
|
||||
|
||||
def _navigate(self, delta: int):
|
||||
if not self._save_current():
|
||||
return
|
||||
self._index = max(0, min(self._index + delta, len(self._track_ids) - 1))
|
||||
self._load_current()
|
||||
|
||||
def _on_ok(self):
|
||||
saved = self._save_multi() if self._multi else self._save_current()
|
||||
if saved:
|
||||
self.accept()
|
||||
305
lintunes/gui/library_view.py
Normal file
305
lintunes/gui/library_view.py
Normal 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
405
lintunes/gui/main_window.py
Normal file
@ -0,0 +1,405 @@
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QMainWindow, QSplitter, QStackedWidget, QWidget, QVBoxLayout, QLineEdit,
|
||||
QPlainTextEdit, QTextEdit, QAbstractSpinBox, QComboBox, QApplication,
|
||||
QLabel, QMessageBox, QFileDialog,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QEvent, QTimer
|
||||
from PyQt6.QtGui import QAction, QKeySequence
|
||||
|
||||
from lintunes import theme
|
||||
from lintunes.inhibit import SleepInhibitor
|
||||
from lintunes.player import Player
|
||||
from lintunes.importers import file_importer
|
||||
from lintunes.gui.sidebar import SidebarPanel
|
||||
from lintunes.gui.library_view import LibraryView
|
||||
from lintunes.gui.playlist_view import PlaylistView
|
||||
from lintunes.gui.transport import TransportBar
|
||||
from lintunes.gui.info_dialog import InfoDialog
|
||||
from lintunes.gui.preferences_dialog import PreferencesDialog
|
||||
from lintunes.gui.track_table import format_total_time
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self, manager, prefs, lastfm=None, parent=None):
|
||||
super().__init__(parent)
|
||||
self._manager = manager
|
||||
self._prefs = prefs
|
||||
self._lastfm = lastfm
|
||||
self._prefs_dialog = None
|
||||
self.player = Player(manager, self)
|
||||
# Keep the machine awake while audio is actually playing.
|
||||
self._inhibitor = SleepInhibitor()
|
||||
# Context playback started from: "library" or "playlist:<pid>". Scopes
|
||||
# the now-playing speaker icon to where the song is actually playing.
|
||||
self._now_playing_context = ""
|
||||
|
||||
self.setWindowTitle("LinTunes")
|
||||
self.resize(1280, 800)
|
||||
# Keep the window's minimum well under typical sizes so the WM never
|
||||
# refuses an interactive shrink
|
||||
self.setMinimumSize(560, 420)
|
||||
self.setAcceptDrops(True)
|
||||
|
||||
central = QWidget()
|
||||
layout = QVBoxLayout(central)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
self.setCentralWidget(central)
|
||||
|
||||
self._transport = TransportBar(self.player, manager, self._prefs)
|
||||
layout.addWidget(self._transport)
|
||||
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
layout.addWidget(splitter, stretch=1)
|
||||
|
||||
self._sidebar = SidebarPanel(manager, self.player)
|
||||
self._sidebar.setMinimumWidth(140)
|
||||
splitter.addWidget(self._sidebar)
|
||||
|
||||
self._content = QStackedWidget()
|
||||
splitter.addWidget(self._content)
|
||||
splitter.setSizes([220, 1060])
|
||||
splitter.setCollapsible(1, False)
|
||||
|
||||
self._library_view = LibraryView(manager)
|
||||
self._content.addWidget(self._library_view)
|
||||
self._playlist_view = PlaylistView(manager)
|
||||
self._content.addWidget(self._playlist_view)
|
||||
self._content.setCurrentWidget(self._library_view)
|
||||
|
||||
# Bottom status bar: a permanent, centered totals readout (transient
|
||||
# import/scrobble/error messages still use the left showMessage area).
|
||||
self._totals_label = QLabel()
|
||||
self._totals_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.statusBar().addPermanentWidget(self._totals_label, 1)
|
||||
|
||||
# Wiring
|
||||
self._transport.play_clicked.connect(self.play_pause)
|
||||
self._sidebar.library_selected.connect(self._show_library)
|
||||
self._sidebar.playlist_selected.connect(self._show_playlist)
|
||||
self._content.currentChanged.connect(self._update_totals)
|
||||
for view in (self._library_view, self._playlist_view):
|
||||
view.play_requested.connect(
|
||||
lambda ids, start, v=view: self._play_from_view(v, ids, start))
|
||||
view.info_requested.connect(self._show_info)
|
||||
view.show_in_playlist_requested.connect(self._show_track_in_playlist)
|
||||
view.table.tracks_changed.connect(self._update_totals)
|
||||
self._playlist_view.files_dropped.connect(self._import_files_to_playlist)
|
||||
self.player.error_occurred.connect(
|
||||
lambda msg: QMessageBox.warning(self, "Can't play track", msg))
|
||||
self.player.track_missing.connect(self._on_track_missing)
|
||||
self.player.track_changed.connect(self._on_track_changed)
|
||||
self.player.playing_changed.connect(self._update_now_playing_icon)
|
||||
self.player.playing_changed.connect(self._on_playing_changed)
|
||||
manager.track_updated.connect(self._on_track_meta_updated)
|
||||
prefs.changed.connect(self._on_prefs_changed)
|
||||
if lastfm is not None:
|
||||
self.player.track_changed.connect(lastfm.handle_track_started)
|
||||
self.player.track_finished.connect(lastfm.handle_track_finished)
|
||||
lastfm.status_message.connect(
|
||||
lambda msg: self.statusBar().showMessage(msg, 4000))
|
||||
|
||||
self._build_menus()
|
||||
self.apply_ui_metrics()
|
||||
self._update_totals()
|
||||
|
||||
# ---- status bar totals ----
|
||||
|
||||
def _update_totals(self):
|
||||
view = self._content.currentWidget()
|
||||
count, total_ms = view.table.total_stats()
|
||||
if count:
|
||||
noun = "track" if count == 1 else "tracks"
|
||||
self._totals_label.setText(
|
||||
f"{count:,} {noun} · {format_total_time(total_ms)}")
|
||||
else:
|
||||
self._totals_label.setText("")
|
||||
|
||||
# ---- preferences / theming ----
|
||||
|
||||
def _on_prefs_changed(self):
|
||||
theme.apply_theme(QApplication.instance(), self._prefs)
|
||||
self.apply_ui_metrics()
|
||||
self._transport.refresh_theme()
|
||||
|
||||
def apply_ui_metrics(self):
|
||||
metrics = theme.scale_metrics(self._prefs)
|
||||
for view in (self._library_view, self._playlist_view):
|
||||
view.table.verticalHeader().setDefaultSectionSize(metrics["row_height"])
|
||||
self._library_view.set_header_height(metrics["header_strip"])
|
||||
self._playlist_view.set_header_height(metrics["header_strip"])
|
||||
self._sidebar.apply_metrics(metrics["header_strip"])
|
||||
self.statusBar().setMinimumHeight(metrics["status_height"])
|
||||
|
||||
def _show_preferences(self):
|
||||
if self._prefs_dialog is None:
|
||||
self._prefs_dialog = PreferencesDialog(self._prefs, self._lastfm, self)
|
||||
self._prefs_dialog.show()
|
||||
self._prefs_dialog.raise_()
|
||||
self._prefs_dialog.activateWindow()
|
||||
|
||||
def _build_menus(self):
|
||||
bar = self.menuBar()
|
||||
|
||||
file_menu = bar.addMenu("&File")
|
||||
self._add_action(file_menu, "New Playlist", "Ctrl+N",
|
||||
lambda: self._sidebar.tree.create_playlist_interactive())
|
||||
self._add_action(file_menu, "New Playlist Folder", "Ctrl+Shift+N",
|
||||
lambda: self._manager.create_folder("untitled folder"))
|
||||
file_menu.addSeparator()
|
||||
self._add_action(file_menu, "Add Files to Library…", "Ctrl+O",
|
||||
self._open_files_dialog)
|
||||
file_menu.addSeparator()
|
||||
self._add_action(file_menu, "Quit", "Ctrl+Q", self.close)
|
||||
|
||||
edit_menu = bar.addMenu("&Edit")
|
||||
self._undo_action = self._add_action(
|
||||
edit_menu, "Undo", QKeySequence.StandardKey.Undo,
|
||||
self._manager.undo_stack.undo)
|
||||
self._redo_action = self._add_action(
|
||||
edit_menu, "Redo", QKeySequence.StandardKey.Redo,
|
||||
self._manager.undo_stack.redo)
|
||||
# Accept both the platform default (Ctrl+Y) and Ctrl+Shift+Z for redo.
|
||||
self._redo_action.setShortcuts([
|
||||
QKeySequence(QKeySequence.StandardKey.Redo),
|
||||
QKeySequence("Ctrl+Shift+Z"),
|
||||
])
|
||||
edit_menu.addSeparator()
|
||||
self._add_action(edit_menu, "Preferences…", "Ctrl+,", self._show_preferences)
|
||||
self._manager.undo_stack.changed.connect(self._refresh_undo_actions)
|
||||
self._refresh_undo_actions()
|
||||
|
||||
controls = bar.addMenu("&Controls")
|
||||
self._add_action(controls, "Play/Pause", "", self.play_pause)
|
||||
self._add_action(controls, "Next", "", self.player.next)
|
||||
self._add_action(controls, "Previous", "", self.player.previous)
|
||||
|
||||
view_menu = bar.addMenu("&View")
|
||||
self._add_action(view_menu, "Toggle Column Browser", "Ctrl+B",
|
||||
self._library_view.toggle_browser)
|
||||
|
||||
track_menu = bar.addMenu("&Track")
|
||||
self._add_action(track_menu, "Get Info", "Ctrl+I", self._info_for_current_view)
|
||||
self._add_action(track_menu, "Go to Current Song", "Ctrl+L",
|
||||
self._go_to_current_song)
|
||||
|
||||
def _add_action(self, menu, text, shortcut, slot):
|
||||
action = QAction(text, self)
|
||||
if shortcut:
|
||||
action.setShortcut(QKeySequence(shortcut))
|
||||
action.triggered.connect(slot)
|
||||
menu.addAction(action)
|
||||
return action
|
||||
|
||||
def _refresh_undo_actions(self):
|
||||
stack = self._manager.undo_stack
|
||||
can_undo = stack.can_undo()
|
||||
can_redo = stack.can_redo()
|
||||
self._undo_action.setEnabled(can_undo)
|
||||
self._redo_action.setEnabled(can_redo)
|
||||
self._undo_action.setText(
|
||||
f"Undo {stack.undo_label()}" if can_undo else "Undo")
|
||||
self._redo_action.setText(
|
||||
f"Redo {stack.redo_label()}" if can_redo else "Redo")
|
||||
|
||||
# ---- view switching ----
|
||||
|
||||
def _show_library(self):
|
||||
self._content.setCurrentWidget(self._library_view)
|
||||
self._update_now_playing_icon()
|
||||
|
||||
def _show_playlist(self, persistent_id: str):
|
||||
playlist = self._manager.library.playlists.get(persistent_id)
|
||||
if playlist:
|
||||
self._playlist_view.show_playlist(playlist)
|
||||
self._content.setCurrentWidget(self._playlist_view)
|
||||
self._update_now_playing_icon()
|
||||
|
||||
def _go_to_current_song(self):
|
||||
"""Jump to wherever the current song is playing from, select it, and
|
||||
scroll it into view. No-op when nothing is cued."""
|
||||
track = self.player.current_track
|
||||
if track is None:
|
||||
return
|
||||
ctx = self._now_playing_context
|
||||
if ctx == "library":
|
||||
self._show_library()
|
||||
self._sidebar.reflect_library()
|
||||
self._library_view.reveal_track(track.track_id)
|
||||
elif ctx.startswith("playlist:"):
|
||||
pid = ctx.split(":", 1)[1]
|
||||
if pid in self._manager.library.playlists:
|
||||
self._show_playlist(pid)
|
||||
self._sidebar.reflect_playlist(pid)
|
||||
self._playlist_view.reveal_track(track.track_id)
|
||||
|
||||
def _show_track_in_playlist(self, track_id: int, pid: str):
|
||||
"""Jump to a playlist and highlight the first instance of the track,
|
||||
reusing the Go-to-Current-Song navigation pattern."""
|
||||
if pid in self._manager.library.playlists:
|
||||
self._show_playlist(pid)
|
||||
self._sidebar.reflect_playlist(pid)
|
||||
self._playlist_view.reveal_track(track_id)
|
||||
|
||||
# ---- info dialog ----
|
||||
|
||||
def _show_info(self, track_ids: list[int]):
|
||||
if track_ids:
|
||||
InfoDialog(self._manager, track_ids, self).exec()
|
||||
|
||||
def _info_for_current_view(self):
|
||||
view = self._content.currentWidget()
|
||||
ids = view.table.selected_track_ids()
|
||||
self._show_info(ids)
|
||||
|
||||
# ---- file import ----
|
||||
|
||||
def _music_import_dir(self) -> Path:
|
||||
return Path(self._manager.library.music_folder) / "Music"
|
||||
|
||||
def _open_files_dialog(self):
|
||||
paths, _filter = QFileDialog.getOpenFileNames(
|
||||
self, "Add Files to Library", "",
|
||||
"Audio files (*.mp3 *.m4a *.flac *.aac *.wav *.aiff)")
|
||||
if paths:
|
||||
self.import_files([Path(p) for p in paths])
|
||||
|
||||
def import_files(self, paths, playlist_pid: str = ""):
|
||||
self.statusBar().showMessage(f"Importing {len(paths)} item(s)…")
|
||||
imported = file_importer.import_paths(
|
||||
paths, self._music_import_dir(), self._manager, playlist_pid)
|
||||
self.statusBar().showMessage(f"Imported {len(imported)} track(s)", 5000)
|
||||
if imported:
|
||||
self._library_view.reload()
|
||||
|
||||
def _import_files_to_playlist(self, paths, pid):
|
||||
self.import_files([Path(p) for p in paths], pid)
|
||||
|
||||
# ---- relocating a moved/renamed file ----
|
||||
|
||||
def _existing_ancestor(self, path: str) -> str:
|
||||
"""Deepest existing directory at or above `path`'s parent, so the file
|
||||
picker opens somewhere real even if the exact folder is gone."""
|
||||
if path:
|
||||
p = Path(path).parent
|
||||
while not p.exists() and p != p.parent:
|
||||
p = p.parent
|
||||
if p.exists():
|
||||
return str(p)
|
||||
music = self._music_import_dir()
|
||||
return str(music) if music.exists() else str(Path.home())
|
||||
|
||||
def _on_track_missing(self, track):
|
||||
"""A track's file is gone (moved/renamed outside lintunes). Offer to
|
||||
relocate it manually; on success, repoint the track and resume."""
|
||||
box = QMessageBox(self)
|
||||
box.setIcon(QMessageBox.Icon.Warning)
|
||||
box.setWindowTitle("Can't play track")
|
||||
box.setText(f"Can't play “{track.name}”.")
|
||||
box.setInformativeText(
|
||||
f"File not found:\n{track.location or '(no location)'}")
|
||||
locate_btn = box.addButton("Locate File…", QMessageBox.ButtonRole.ActionRole)
|
||||
box.addButton(QMessageBox.StandardButton.Cancel)
|
||||
box.exec()
|
||||
if box.clickedButton() is not locate_btn:
|
||||
return
|
||||
chosen, _filter = QFileDialog.getOpenFileName(
|
||||
self, f"Locate “{track.name}”", self._existing_ancestor(track.location),
|
||||
"Audio files (*.mp3 *.m4a *.flac *.aac *.wav *.aiff *.aif);;All files (*)")
|
||||
if not chosen:
|
||||
return
|
||||
self._manager.set_track_location(track.track_id, chosen)
|
||||
# Defer the retry so it runs after this modal handler unwinds.
|
||||
QTimer.singleShot(0, self.player.retry_current)
|
||||
|
||||
# ---- window-level drops (import anywhere) ----
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
if event.mimeData().hasUrls():
|
||||
event.acceptProposedAction()
|
||||
|
||||
def dropEvent(self, event):
|
||||
paths = [Path(u.toLocalFile()) for u in event.mimeData().urls()
|
||||
if u.isLocalFile()]
|
||||
if paths:
|
||||
self.import_files(paths)
|
||||
event.acceptProposedAction()
|
||||
|
||||
# ---- playback ----
|
||||
|
||||
def play_pause(self):
|
||||
"""Play/pause like iTunes: with nothing cued, start the visible
|
||||
view's tracks (from the selected row, else the top)."""
|
||||
if self.player.current_track is not None:
|
||||
self.player.toggle_play()
|
||||
return
|
||||
view = self._content.currentWidget()
|
||||
ids = view.table.view_order_track_ids()
|
||||
if not ids:
|
||||
return
|
||||
selected = view.table.selectionModel().selectedRows()
|
||||
start = selected[0].row() if selected else 0
|
||||
self._play_from_view(view, ids, start)
|
||||
|
||||
def _play_from_view(self, view, ids, start):
|
||||
"""Start playback, recording which view it came from so the now-playing
|
||||
icon (and Go to Current Song) can find the right context."""
|
||||
self._now_playing_context = view.table.context_id()
|
||||
self.player.play_queue(ids, start)
|
||||
|
||||
def _on_track_changed(self, track):
|
||||
if track is None:
|
||||
self.setWindowTitle("LinTunes")
|
||||
else:
|
||||
self.setWindowTitle(f"{track.name} — {track.artist} · LinTunes")
|
||||
self._update_now_playing_icon()
|
||||
|
||||
def _on_track_meta_updated(self, track_id: int):
|
||||
current = self.player.current_track
|
||||
if current is not None and current.track_id == track_id:
|
||||
self._sidebar.art.refresh()
|
||||
|
||||
def _update_now_playing_icon(self, _playing=None):
|
||||
track = self.player.current_track
|
||||
track_id = track.track_id if track else None
|
||||
playing = self.player.is_playing()
|
||||
# Only the table showing the context playback started from gets the icon,
|
||||
# so the same song appearing in another playlist/library isn't flagged.
|
||||
for view in (self._library_view, self._playlist_view):
|
||||
match = (track_id is not None
|
||||
and view.table.context_id() == self._now_playing_context)
|
||||
view.table.model_.set_now_playing(track_id if match else None,
|
||||
playing)
|
||||
|
||||
# ---- global media keys (spacebar / arrows), per spec detail 8 ----
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
if event.type() == QEvent.Type.KeyPress and self.isActiveWindow():
|
||||
focus = QApplication.focusWidget()
|
||||
if isinstance(focus, (QLineEdit, QPlainTextEdit, QTextEdit,
|
||||
QAbstractSpinBox, QComboBox)):
|
||||
return False
|
||||
key = event.key()
|
||||
if key == Qt.Key.Key_Space:
|
||||
self.play_pause()
|
||||
return True
|
||||
if key == Qt.Key.Key_Right:
|
||||
self.player.next()
|
||||
return True
|
||||
if key == Qt.Key.Key_Left:
|
||||
self.player.previous()
|
||||
return True
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def _on_playing_changed(self, playing: bool):
|
||||
if playing:
|
||||
self._inhibitor.inhibit()
|
||||
else:
|
||||
self._inhibitor.release()
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._inhibitor.release()
|
||||
self._manager.flush()
|
||||
super().closeEvent(event)
|
||||
67
lintunes/gui/playlist_ops.py
Normal file
67
lintunes/gui/playlist_ops.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""Shared playlist-add logic with a duplicate-confirmation prompt.
|
||||
|
||||
All GUI paths that add tracks to a playlist (sidebar drop, drop onto an open
|
||||
playlist's track list, paste) route through here so the duplicate guard is
|
||||
consistent for single- and multi-track adds.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
|
||||
def has_duplicates(track_ids, existing) -> bool:
|
||||
"""Whether any of ``track_ids`` is already in ``existing``."""
|
||||
existing = set(existing)
|
||||
return any(tid in existing for tid in track_ids)
|
||||
|
||||
|
||||
def filter_for_add(track_ids, existing, add_duplicates: bool) -> list[int]:
|
||||
"""The ids to add, preserving order: all of them when ``add_duplicates``,
|
||||
otherwise only the ones not already in ``existing``."""
|
||||
if add_duplicates:
|
||||
return list(track_ids)
|
||||
existing = set(existing)
|
||||
return [tid for tid in track_ids if tid not in existing]
|
||||
|
||||
|
||||
def add_tracks_with_dup_check(manager, parent, pid, track_ids,
|
||||
position=None) -> list[int]:
|
||||
"""Add ``track_ids`` to playlist ``pid``, asking first if any already exist.
|
||||
|
||||
Returns the track ids actually added (empty if the user cancelled or there
|
||||
was nothing to add).
|
||||
"""
|
||||
playlist = manager.library.playlists.get(pid)
|
||||
if not playlist or not track_ids:
|
||||
return []
|
||||
|
||||
existing = playlist.track_ids
|
||||
if has_duplicates(track_ids, existing):
|
||||
to_add = _resolve_duplicates(parent, track_ids, existing)
|
||||
else:
|
||||
to_add = list(track_ids)
|
||||
|
||||
if not to_add:
|
||||
return []
|
||||
manager.add_tracks_to_playlist(pid, to_add, position)
|
||||
return to_add
|
||||
|
||||
|
||||
def _resolve_duplicates(parent, track_ids, existing) -> list[int]:
|
||||
"""Ask whether to re-add duplicates; return the resulting ids to add."""
|
||||
box = QMessageBox(parent)
|
||||
box.setIcon(QMessageBox.Icon.Question)
|
||||
box.setWindowTitle("Duplicate songs")
|
||||
box.setText("One or more songs are already in the playlist. "
|
||||
"Add those ones again?")
|
||||
add_again = box.addButton("Yes, add again",
|
||||
QMessageBox.ButtonRole.AcceptRole)
|
||||
skip = box.addButton("Skip duplicates", QMessageBox.ButtonRole.ActionRole)
|
||||
box.setDefaultButton(skip)
|
||||
box.exec()
|
||||
|
||||
clicked = box.clickedButton()
|
||||
if clicked is add_again:
|
||||
return filter_for_add(track_ids, existing, add_duplicates=True)
|
||||
if clicked is skip:
|
||||
return filter_for_add(track_ids, existing, add_duplicates=False)
|
||||
return [] # dialog dismissed (Escape / close) -> add nothing
|
||||
143
lintunes/gui/playlist_view.py
Normal file
143
lintunes/gui/playlist_view.py
Normal file
@ -0,0 +1,143 @@
|
||||
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QApplication
|
||||
from PyQt6.QtCore import pyqtSignal
|
||||
|
||||
from lintunes.gui.track_table import TrackTableView, parse_tracks_mime
|
||||
from lintunes.gui.playlist_ops import add_tracks_with_dup_check
|
||||
|
||||
|
||||
class PlaylistView(QWidget):
|
||||
play_requested = pyqtSignal(list, int)
|
||||
info_requested = pyqtSignal(list)
|
||||
files_dropped = pyqtSignal(list, str) # paths, playlist pid
|
||||
show_in_playlist_requested = pyqtSignal(int, str) # track_id, playlist pid
|
||||
|
||||
def __init__(self, manager, parent=None):
|
||||
super().__init__(parent)
|
||||
self._manager = manager
|
||||
self._playlist = None
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
self._name_label = QLabel()
|
||||
self._name_label.setStyleSheet("font-weight: bold; padding: 0 8px;")
|
||||
self._name_label.setFixedHeight(34)
|
||||
layout.addWidget(self._name_label)
|
||||
|
||||
self.table = TrackTableView(playlist_mode=True)
|
||||
layout.addWidget(self.table)
|
||||
|
||||
self.table.playlists_for_track = manager.playlists_containing
|
||||
self.table.play_requested.connect(self.play_requested)
|
||||
self.table.info_requested.connect(self.info_requested)
|
||||
self.table.show_in_playlist_requested.connect(
|
||||
self.show_in_playlist_requested)
|
||||
self.table.sort_changed.connect(self._on_sort_changed)
|
||||
self.table.columns_changed.connect(self._on_columns_changed)
|
||||
self.table.column_width_changed.connect(self._on_width_changed)
|
||||
self.table.reorder_requested.connect(self._on_reorder)
|
||||
self.table.tracks_dropped.connect(self._on_tracks_dropped)
|
||||
self.table.files_dropped.connect(self._on_files_dropped)
|
||||
self.table.remove_requested.connect(self._on_remove)
|
||||
self.table.paste_requested.connect(self._on_paste)
|
||||
|
||||
manager.playlist_content_changed.connect(self._on_content_changed)
|
||||
manager.track_updated.connect(self.table.model_.refresh_track)
|
||||
manager.playlists_changed.connect(self._on_playlists_changed)
|
||||
|
||||
def set_header_height(self, height: int):
|
||||
# Kept in sync with the sidebar's Library button so the tree's top
|
||||
# aligns with the tracklist's top
|
||||
self._name_label.setFixedHeight(height)
|
||||
|
||||
@property
|
||||
def playlist_id(self) -> str:
|
||||
return self._playlist.persistent_id if self._playlist else ""
|
||||
|
||||
def reveal_track(self, track_id: int):
|
||||
self.table.reveal_track(track_id)
|
||||
|
||||
def show_playlist(self, playlist):
|
||||
self._playlist = playlist
|
||||
self.table.set_source_playlist(playlist.persistent_id)
|
||||
self.table.apply_settings(playlist.settings)
|
||||
self._reload_tracks()
|
||||
|
||||
def _reload_tracks(self):
|
||||
if self._playlist is None:
|
||||
return
|
||||
tracks = [self._manager.library.tracks[tid]
|
||||
for tid in self._playlist.track_ids
|
||||
if tid in self._manager.library.tracks]
|
||||
self.table.set_tracks(tracks)
|
||||
# Count/time now live in the bottom status bar, not next to the name.
|
||||
self._name_label.setText(self._playlist.name)
|
||||
|
||||
def _on_content_changed(self, pid):
|
||||
if self._playlist is not None and pid == self._playlist.persistent_id:
|
||||
self._reload_tracks()
|
||||
|
||||
def _on_playlists_changed(self):
|
||||
# Playlist may have been renamed or deleted
|
||||
if self._playlist is None:
|
||||
return
|
||||
if self._playlist.persistent_id not in self._manager.library.playlists:
|
||||
self._playlist = None
|
||||
self.table.set_tracks([])
|
||||
self._name_label.setText("")
|
||||
else:
|
||||
self._reload_tracks()
|
||||
|
||||
# ---- settings persistence ----
|
||||
|
||||
def _on_sort_changed(self, field, ascending):
|
||||
if self._playlist is None:
|
||||
return
|
||||
self._playlist.settings.sort_column = field
|
||||
self._playlist.settings.sort_ascending = ascending
|
||||
self._manager.mark_playlist_settings_dirty(self._playlist.persistent_id)
|
||||
|
||||
def _on_columns_changed(self, columns):
|
||||
if self._playlist is None:
|
||||
return
|
||||
self._playlist.settings.visible_columns = columns
|
||||
self.table.apply_settings(self._playlist.settings)
|
||||
self._manager.mark_playlist_settings_dirty(self._playlist.persistent_id)
|
||||
|
||||
def _on_width_changed(self, field, width):
|
||||
if self._playlist is None:
|
||||
return
|
||||
self._playlist.settings.column_widths[field] = width
|
||||
self._manager.mark_playlist_settings_dirty(self._playlist.persistent_id)
|
||||
|
||||
# ---- content edits ----
|
||||
|
||||
def _on_reorder(self, rows, dest):
|
||||
if self._playlist is not None:
|
||||
self._manager.move_tracks_in_playlist(
|
||||
self._playlist.persistent_id, rows, dest)
|
||||
|
||||
def _on_tracks_dropped(self, track_ids, drop_row):
|
||||
if self._playlist is not None:
|
||||
add_tracks_with_dup_check(
|
||||
self._manager, self, self._playlist.persistent_id,
|
||||
track_ids, drop_row)
|
||||
|
||||
def _on_files_dropped(self, paths, drop_row):
|
||||
if self._playlist is not None:
|
||||
self.files_dropped.emit(paths, self._playlist.persistent_id)
|
||||
|
||||
def _on_remove(self, rows):
|
||||
if self._playlist is not None:
|
||||
self._manager.remove_tracks_from_playlist(
|
||||
self._playlist.persistent_id, rows)
|
||||
|
||||
def _on_paste(self):
|
||||
if self._playlist is None:
|
||||
return
|
||||
payload = parse_tracks_mime(QApplication.clipboard().mimeData())
|
||||
if payload:
|
||||
add_tracks_with_dup_check(
|
||||
self._manager, self, self._playlist.persistent_id,
|
||||
payload.get("track_ids", []))
|
||||
154
lintunes/gui/preferences_dialog.py
Normal file
154
lintunes/gui/preferences_dialog.py
Normal 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
409
lintunes/gui/sidebar.py
Normal 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
747
lintunes/gui/track_table.py
Normal 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
360
lintunes/gui/transport.py
Normal 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
187
lintunes/gui/visualizer.py
Normal 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)
|
||||
0
lintunes/importers/__init__.py
Normal file
0
lintunes/importers/__init__.py
Normal file
106
lintunes/importers/file_importer.py
Normal file
106
lintunes/importers/file_importer.py
Normal file
@ -0,0 +1,106 @@
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from lintunes.models import Track
|
||||
from lintunes import tagging
|
||||
|
||||
|
||||
AUDIO_EXTENSIONS = {".mp3", ".m4a", ".flac", ".aac", ".wav", ".aiff", ".aif"}
|
||||
|
||||
_UNSAFE_CHARS = re.compile(r'[/\\:*?"<>|\x00-\x1f]')
|
||||
|
||||
|
||||
def is_audio_file(path: Path) -> bool:
|
||||
return path.suffix.lower() in AUDIO_EXTENSIONS
|
||||
|
||||
|
||||
def sanitize_component(name: str) -> str:
|
||||
name = _UNSAFE_CHARS.sub("_", name).strip().rstrip(".")
|
||||
return name or "Unknown"
|
||||
|
||||
|
||||
def organized_destination(music_dir: Path, fields: dict, filename: str) -> Path:
|
||||
"""<music_dir>/<Artist>/<Album>/<filename>, like iTunes' folder layout."""
|
||||
artist = sanitize_component(fields.get("album_artist") or fields.get("artist") or "Unknown Artist")
|
||||
album = sanitize_component(fields.get("album") or "Unknown Album")
|
||||
return music_dir / artist / album / sanitize_component(filename)
|
||||
|
||||
|
||||
def unique_path(dest: Path) -> Path:
|
||||
if not dest.exists():
|
||||
return dest
|
||||
for i in range(1, 1000):
|
||||
candidate = dest.with_stem(f"{dest.stem} {i}")
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
raise FileExistsError(f"Could not find a free name for {dest}")
|
||||
|
||||
|
||||
def import_file(source: Path, music_dir: Path, manager) -> Track | None:
|
||||
"""Copy an external audio file into the organized music dir and add it
|
||||
to the library. Returns the new Track, or the existing one if this exact
|
||||
file is already in the library."""
|
||||
source = Path(source).resolve()
|
||||
if not source.is_file() or not is_audio_file(source):
|
||||
return None
|
||||
|
||||
fields = tagging.read_tags(source)
|
||||
|
||||
# Already in the library? (same target path, or source is inside music_dir)
|
||||
for track in manager.library.tracks.values():
|
||||
if track.location and Path(track.location) == source:
|
||||
return track
|
||||
|
||||
if music_dir in source.parents:
|
||||
dest = source # already organized inside the music dir; don't copy
|
||||
else:
|
||||
dest = organized_destination(music_dir, fields, source.name)
|
||||
for track in manager.library.tracks.values():
|
||||
if track.location and Path(track.location) == dest:
|
||||
return track
|
||||
dest = unique_path(dest)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, dest)
|
||||
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
|
||||
track = Track(
|
||||
name=fields.get("name") or source.stem,
|
||||
location=str(dest),
|
||||
date_added=now,
|
||||
date_modified=now,
|
||||
)
|
||||
for key in ("artist", "album_artist", "album", "genre", "composer",
|
||||
"year", "track_number", "track_count", "disc_number", "disc_count",
|
||||
"comments", "total_time", "bit_rate", "sample_rate", "size", "kind"):
|
||||
if key in fields:
|
||||
setattr(track, key, fields[key])
|
||||
manager.add_track(track)
|
||||
return track
|
||||
|
||||
|
||||
def import_paths(paths: list[Path], music_dir: Path, manager,
|
||||
playlist_pid: str = "") -> list[Track]:
|
||||
"""Import files and directories (recursively); optionally add to a playlist."""
|
||||
files: list[Path] = []
|
||||
for path in paths:
|
||||
path = Path(path)
|
||||
if path.is_dir():
|
||||
files.extend(sorted(p for p in path.rglob("*") if p.is_file() and is_audio_file(p)))
|
||||
elif path.is_file() and is_audio_file(path):
|
||||
files.append(path)
|
||||
|
||||
imported = []
|
||||
for file_path in files:
|
||||
try:
|
||||
track = import_file(file_path, music_dir, manager)
|
||||
except Exception as e:
|
||||
print(f"Failed to import {file_path}: {e}")
|
||||
continue
|
||||
if track:
|
||||
imported.append(track)
|
||||
|
||||
if playlist_pid and imported:
|
||||
manager.add_tracks_to_playlist(playlist_pid, [t.track_id for t in imported])
|
||||
return imported
|
||||
174
lintunes/importers/itunes_importer.py
Normal file
174
lintunes/importers/itunes_importer.py
Normal 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
45
lintunes/inhibit.py
Normal 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
56
lintunes/itc.py
Normal 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
199
lintunes/lastfm.py
Normal 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
434
lintunes/library_manager.py
Normal 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
173
lintunes/main.py
Normal file
@ -0,0 +1,173 @@
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from lintunes.config import load_config, save_config
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="lintunes",
|
||||
description="LinTunes — iTunes library manager for Linux",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--import-xml",
|
||||
dest="import_file",
|
||||
metavar="XML_FILE",
|
||||
help="Import an iTunes Library XML file and exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--music-root",
|
||||
help="Local directory corresponding to iTunes' Music Folder "
|
||||
"(the 'iTunes Media' directory)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--data-dir",
|
||||
help="Directory holding the lintunes library JSON files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--save-config",
|
||||
action="store_true",
|
||||
help="Persist --music-root/--data-dir as defaults",
|
||||
)
|
||||
parser.add_argument(
|
||||
"files",
|
||||
nargs="*",
|
||||
help="Audio files to import into the library on startup",
|
||||
)
|
||||
|
||||
# Let Qt flags like `-platform xcb` pass through to QApplication
|
||||
args, qt_args = parser.parse_known_args()
|
||||
config = load_config()
|
||||
music_root = args.music_root or config.get("music_root")
|
||||
data_dir = args.data_dir or config.get("data_dir")
|
||||
|
||||
if args.save_config and (args.music_root or args.data_dir):
|
||||
config.update({k: v for k, v in
|
||||
(("music_root", args.music_root), ("data_dir", args.data_dir))
|
||||
if v})
|
||||
save_config(config)
|
||||
print(f"Saved config: {config}")
|
||||
|
||||
if not data_dir:
|
||||
print("Error: no data directory configured.\n"
|
||||
"Run with --data-dir DIR (add --save-config to remember it).")
|
||||
sys.exit(1)
|
||||
data_dir = Path(data_dir)
|
||||
|
||||
if args.import_file:
|
||||
run_import(Path(args.import_file), music_root, data_dir)
|
||||
else:
|
||||
run_gui(data_dir, [Path(f) for f in args.files], qt_args)
|
||||
|
||||
|
||||
def run_import(xml_path: Path, music_root: str | None, data_dir: Path):
|
||||
from lintunes.importers.itunes_importer import import_itunes_xml
|
||||
from lintunes.storage.json_storage import save_library
|
||||
|
||||
if not xml_path.exists():
|
||||
print(f"Error: File not found: {xml_path}")
|
||||
sys.exit(1)
|
||||
if not music_root:
|
||||
print("Error: --music-root is required for import "
|
||||
"(the local path of iTunes' 'iTunes Media' folder)")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Importing from {xml_path}...")
|
||||
library, report = import_itunes_xml(xml_path, Path(music_root))
|
||||
print(report.summary())
|
||||
|
||||
if report.missing_files:
|
||||
missing_log = data_dir / "import_missing_files.txt"
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
missing_log.write_text("\n".join(report.missing_files), encoding="utf-8")
|
||||
print(f"Missing file list written to {missing_log}")
|
||||
if report.unmapped_locations:
|
||||
unmapped_log = data_dir / "import_unmapped_locations.txt"
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
unmapped_log.write_text("\n".join(report.unmapped_locations), encoding="utf-8")
|
||||
print(f"Unmapped location list written to {unmapped_log}")
|
||||
|
||||
print(f"Saving library to {data_dir}...")
|
||||
save_library(library, data_dir)
|
||||
print("Import complete!")
|
||||
|
||||
|
||||
def run_gui(data_dir: Path, files: list[Path], qt_args: list[str] | None = None):
|
||||
from PyQt6.QtWidgets import QApplication, QMessageBox, QFontDialog
|
||||
|
||||
from lintunes import theme
|
||||
from lintunes.storage.json_storage import load_library
|
||||
from lintunes.storage.conflict_resolver import resolve_conflicts
|
||||
from lintunes.library_manager import LibraryManager
|
||||
from lintunes.preferences import Preferences
|
||||
from lintunes.lastfm import LastFm
|
||||
from lintunes.gui.main_window import MainWindow
|
||||
from lintunes.mpris import setup_mpris
|
||||
|
||||
resolve_conflicts(data_dir)
|
||||
library = load_library(data_dir)
|
||||
|
||||
app = QApplication([sys.argv[0]] + (qt_args or []))
|
||||
app.setApplicationName("LinTunes")
|
||||
app.setDesktopFileName("lintunes")
|
||||
|
||||
prefs = Preferences(data_dir)
|
||||
theme.apply_theme(app, prefs)
|
||||
_ensure_now_playing_font(prefs)
|
||||
manager = LibraryManager(library, data_dir)
|
||||
lastfm = LastFm(prefs, data_dir)
|
||||
window = MainWindow(manager, prefs, lastfm)
|
||||
app.installEventFilter(window)
|
||||
mpris = setup_mpris(window.player, window) # noqa: F841 — keep alive
|
||||
app.aboutToQuit.connect(manager.flush)
|
||||
|
||||
window.show()
|
||||
if files:
|
||||
window.import_files(files)
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
def _ensure_now_playing_font(prefs):
|
||||
"""Prompt for a now-playing font only when needed, then persist the choice.
|
||||
|
||||
Stays silent when Century Gothic is installed (and the user hasn't picked a
|
||||
substitute), or when the user has already chosen "use default". Prompts
|
||||
when the desired family is missing, offering "Pick a font…" (QFontDialog)
|
||||
or "Use default". Canceling the picker leaves prefs untouched so it
|
||||
re-prompts next launch (lets the user install Century Gothic and retry).
|
||||
"""
|
||||
from PyQt6.QtWidgets import QMessageBox, QFontDialog
|
||||
|
||||
from lintunes import theme
|
||||
desired = theme.now_playing_font_family(prefs)
|
||||
if desired is None and prefs.get("now_playing_font") == "":
|
||||
return # user already chose the app default
|
||||
if desired is not None:
|
||||
return # resolved to an installed family (CG or a saved substitute)
|
||||
|
||||
saved = prefs.get("now_playing_font")
|
||||
if saved is None:
|
||||
msg = ("LinTunes looks best with Century Gothic, which isn't installed. "
|
||||
"Install it (~/.local/share/fonts/) and restart, or pick a font "
|
||||
"to use for the now-playing panel now.")
|
||||
else:
|
||||
msg = (f"The font saved for the now-playing panel ({saved}) is no "
|
||||
"longer installed. Pick a different font, or use the app default.")
|
||||
box = QMessageBox(QMessageBox.Icon.Warning, "Now-playing font", msg)
|
||||
pick = box.addButton("Pick a font…", QMessageBox.ButtonRole.AcceptRole)
|
||||
default = box.addButton("Use default", QMessageBox.ButtonRole.RejectRole)
|
||||
box.exec()
|
||||
if box.clickedButton() is default:
|
||||
prefs.set("now_playing_font", "")
|
||||
return
|
||||
if box.clickedButton() is not pick:
|
||||
return # closed via X / escape → leave untouched, re-prompt next run
|
||||
font, ok = QFontDialog.getFont()
|
||||
if not ok or font.family() in ("", None):
|
||||
return
|
||||
prefs.set("now_playing_font", font.family())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
5
lintunes/models/__init__.py
Normal file
5
lintunes/models/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
from .track import Track
|
||||
from .playlist import Playlist, PlaylistSettings, PlaylistType
|
||||
from .library import Library
|
||||
|
||||
__all__ = ["Track", "Playlist", "PlaylistSettings", "PlaylistType", "Library"]
|
||||
16
lintunes/models/library.py
Normal file
16
lintunes/models/library.py
Normal 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
117
lintunes/models/playlist.py
Normal 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
166
lintunes/models/track.py
Normal 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
252
lintunes/mpris.py
Normal 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
293
lintunes/player.py
Normal 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
73
lintunes/preferences.py
Normal 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
|
||||
0
lintunes/storage/__init__.py
Normal file
0
lintunes/storage/__init__.py
Normal file
165
lintunes/storage/conflict_resolver.py
Normal file
165
lintunes/storage/conflict_resolver.py
Normal file
@ -0,0 +1,165 @@
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from lintunes.models import Track, Playlist
|
||||
|
||||
|
||||
# Syncthing conflict file pattern: filename.sync-conflict-20240101-123456-ABCDEFG.json
|
||||
CONFLICT_PATTERN = re.compile(r"^(.+)\.sync-conflict-(\d{8}-\d{6})-\w+(\.\w+)$")
|
||||
|
||||
|
||||
def resolve_conflicts(data_dir: Path):
|
||||
if not data_dir.exists():
|
||||
return
|
||||
|
||||
resolved_dir = data_dir / ".resolved"
|
||||
conflict_files = _find_conflict_files(data_dir)
|
||||
|
||||
if not conflict_files:
|
||||
return
|
||||
|
||||
print(f"Found {len(conflict_files)} Syncthing conflict(s), resolving...")
|
||||
resolved_dir.mkdir(exist_ok=True)
|
||||
|
||||
for conflict_path, original_path in conflict_files:
|
||||
if not original_path.exists():
|
||||
# Original was deleted; just archive the conflict
|
||||
_archive(conflict_path, resolved_dir)
|
||||
continue
|
||||
|
||||
try:
|
||||
if original_path.name == "library.json":
|
||||
_merge_library(original_path, conflict_path)
|
||||
elif original_path.name == "library_metadata.json":
|
||||
_merge_metadata(original_path, conflict_path)
|
||||
elif original_path.parent.name == "playlists":
|
||||
_merge_playlist(original_path, conflict_path)
|
||||
else:
|
||||
# Unknown file type — keep the newer one
|
||||
_keep_newer(original_path, conflict_path)
|
||||
except Exception as e:
|
||||
print(f" Warning: Failed to merge {conflict_path.name}: {e}")
|
||||
|
||||
_archive(conflict_path, resolved_dir)
|
||||
|
||||
print(f" Resolved {len(conflict_files)} conflict(s)")
|
||||
|
||||
|
||||
def _find_conflict_files(data_dir: Path) -> list[tuple[Path, Path]]:
|
||||
results = []
|
||||
for path in data_dir.rglob("*.sync-conflict-*"):
|
||||
match = CONFLICT_PATTERN.match(path.name)
|
||||
if match:
|
||||
original_name = match.group(1) + match.group(3)
|
||||
original_path = path.parent / original_name
|
||||
results.append((path, original_path))
|
||||
return results
|
||||
|
||||
|
||||
def _merge_library(original_path: Path, conflict_path: Path):
|
||||
original = _load_json(original_path)
|
||||
conflict = _load_json(conflict_path)
|
||||
|
||||
for tid, conflict_track_data in conflict.items():
|
||||
if tid not in original:
|
||||
# New track from conflict side
|
||||
original[tid] = conflict_track_data
|
||||
continue
|
||||
|
||||
orig_track = original[tid]
|
||||
_merge_track_fields(orig_track, conflict_track_data)
|
||||
|
||||
_save_json(original_path, original)
|
||||
|
||||
|
||||
def _merge_track_fields(orig: dict, conflict: dict):
|
||||
# play_count, skip_count: take max
|
||||
for field in ("play_count", "skip_count"):
|
||||
if field in conflict:
|
||||
orig[field] = max(orig.get(field, 0), conflict[field])
|
||||
|
||||
# play_date_utc: take most recent
|
||||
for field in ("play_date_utc", "skip_date"):
|
||||
if field in conflict:
|
||||
orig_val = orig.get(field)
|
||||
conf_val = conflict[field]
|
||||
if orig_val is None or (conf_val and conf_val > orig_val):
|
||||
orig[field] = conf_val
|
||||
|
||||
# date_added: take earliest
|
||||
if "date_added" in conflict:
|
||||
orig_val = orig.get("date_added")
|
||||
conf_val = conflict["date_added"]
|
||||
if orig_val is None or (conf_val and conf_val < orig_val):
|
||||
orig["date_added"] = conf_val
|
||||
|
||||
# loved: OR (true wins)
|
||||
if conflict.get("loved", False):
|
||||
orig["loved"] = True
|
||||
|
||||
# rating, metadata: newest date_modified wins
|
||||
orig_mod = orig.get("date_modified")
|
||||
conf_mod = conflict.get("date_modified")
|
||||
if conf_mod and (not orig_mod or conf_mod > orig_mod):
|
||||
# Conflict is newer — take its metadata fields
|
||||
for field in ("rating", "name", "artist", "album_artist", "album",
|
||||
"genre", "composer", "grouping", "comments",
|
||||
"sort_name", "sort_artist", "sort_album_artist",
|
||||
"sort_album", "sort_composer", "date_modified"):
|
||||
if field in conflict:
|
||||
orig[field] = conflict[field]
|
||||
|
||||
|
||||
def _merge_playlist(original_path: Path, conflict_path: Path):
|
||||
original = _load_json(original_path)
|
||||
conflict = _load_json(conflict_path)
|
||||
|
||||
# Union track_ids, order from the newer file
|
||||
orig_ids = set(original.get("track_ids", []))
|
||||
conf_ids = set(conflict.get("track_ids", []))
|
||||
all_ids = orig_ids | conf_ids
|
||||
|
||||
# Determine which file is newer by checking file mtime
|
||||
if conflict_path.stat().st_mtime > original_path.stat().st_mtime:
|
||||
# Use conflict's order as base, append any missing from original
|
||||
base_order = conflict.get("track_ids", [])
|
||||
extra = [tid for tid in original.get("track_ids", []) if tid not in conf_ids]
|
||||
original["track_ids"] = base_order + extra
|
||||
# Settings from newer file
|
||||
if "settings" in conflict:
|
||||
original["settings"] = conflict["settings"]
|
||||
else:
|
||||
# Original is newer — append missing from conflict
|
||||
extra = [tid for tid in conflict.get("track_ids", []) if tid not in orig_ids]
|
||||
original["track_ids"] = original.get("track_ids", []) + extra
|
||||
|
||||
_save_json(original_path, original)
|
||||
|
||||
|
||||
def _merge_metadata(original_path: Path, conflict_path: Path):
|
||||
# Simple: keep the newer file's content
|
||||
_keep_newer(original_path, conflict_path)
|
||||
|
||||
|
||||
def _keep_newer(original_path: Path, conflict_path: Path):
|
||||
if conflict_path.stat().st_mtime > original_path.stat().st_mtime:
|
||||
shutil.copy2(conflict_path, original_path)
|
||||
|
||||
|
||||
def _archive(conflict_path: Path, resolved_dir: Path):
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
archive_name = f"{timestamp}_{conflict_path.name}"
|
||||
shutil.move(str(conflict_path), str(resolved_dir / archive_name))
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _save_json(path: Path, data: dict):
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
106
lintunes/storage/json_storage.py
Normal file
106
lintunes/storage/json_storage.py
Normal 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
256
lintunes/tagging.py
Normal 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
34
lintunes/tap_tempo.py
Normal 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
96
lintunes/theme.py
Normal 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
71
lintunes/undo.py
Normal 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
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
34
packaging/install-desktop.sh
Executable 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."
|
||||
BIN
packaging/lintunes-shitty.png
Normal file
BIN
packaging/lintunes-shitty.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
11
packaging/lintunes.desktop
Normal file
11
packaging/lintunes.desktop
Normal 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
BIN
packaging/lintunes.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
4
requirements.txt
Normal file
4
requirements.txt
Normal 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
179
scripts/audit_artwork.py
Normal 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()
|
||||
82
scripts/clear_computed_ratings.py
Normal file
82
scripts/clear_computed_ratings.py
Normal 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
170
scripts/recover_artwork.py
Normal 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
22
setup.py
Normal 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
33
spec.md
Normal 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
102
tasks-done.md
Normal 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 1–4 (2026-06-12)
|
||||
|
||||
- **Spec 1–14** — 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
72
tests/conftest.py
Normal 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()
|
||||
149
tests/test_conflict_resolver.py
Normal file
149
tests/test_conflict_resolver.py
Normal 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
118
tests/test_file_importer.py
Normal 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
|
||||
184
tests/test_itunes_importer.py
Normal file
184
tests/test_itunes_importer.py
Normal 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
175
tests/test_models.py
Normal 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
107
tests/test_player.py
Normal 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
47
tests/test_relocate.py
Normal 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
105
tests/test_round3.py
Normal 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
46
tests/test_round5.py
Normal 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
59
tests/test_round6.py
Normal 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
14
tests/test_round7.py
Normal 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
254
tests/test_round8.py
Normal 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
164
tests/test_round9.py
Normal 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
60
tests/test_tagging.py
Normal 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
49
tests/test_tap_tempo.py
Normal 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
56
tests/test_track_edit.py
Normal 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
178
tests/test_undo.py
Normal 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"
|
||||
Reference in New Issue
Block a user