Round 17: task batch + cruft sweep (ratings, art download, MPRIS/exit/BT fixes)

Features:
- Rating hover dots: hovering a rating cell shows 5 clickable slots
  (RatingDelegate); click slot k sets k stars, clicking the current count
  clears. Undoable, library-only (ratings never rewrite music files).
- Search bar moved into the Library header strip, right of the Library
  button, so the tracklist top aligns with the playlist tree.
- Right-click "Download Album Art…": iTunes Search API (no key), off-thread
  fetch, preview/confirm dialog with Next Result, embeds via write_artwork
  + size refresh, invalidates the MPRIS art cache.

Fixes:
- MPRIS media keys: PropertiesChanged sent invalidated_properties as "av"
  instead of "as", so gsd-media-keys dropped it and never MRU-bumped
  lintunes (why the play/pause key kept waking stale players). Now an
  explicit QDBusArgument string array; loopback-verified sa{sv}as.
- Exit segfault: ordered Player.shutdown() (stop, clear source, detach
  buffer/audio outputs) from closeEvent/aboutToQuit; scripted run exits 0.
- BT zero-volume after pause/resume: volume re-applied on resume, device
  swap, and BufferedMedia (needs verify on the affected machine).

Cruft sweep:
- Tag writes filtered to EDITABLE_FIELDS; failures logged + surfaced in
  the status bar (was a swallowed print).
- O(n²) import fixed (location index + cached max track id); O(1)
  refresh/reveal via TrackTableModel row index.
- lastfm: scrobble-queue thread lock, login prefs write marshalled to the
  GUI thread, JSON/raise_for_status order fixed.
- Shared read_json/write_json; TableSettingsMixin dedupes view settings.

TASKS.md rewritten as a resumable board; tests in tests/test_round17.py
(251 total pass).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 11:36:11 -04:00
parent 9575f0400f
commit 8dc33fa65e
18 changed files with 1203 additions and 197 deletions

171
TASKS.md
View File

@ -1,84 +1,109 @@
# LinTunes — master task list
# LinTunes — task board
Legend: `[ ]` todo · `[~]` in progress · `[x]` done.
When a round closes, move its finished items to `tasks-done.md`.
## Open / future
## Round 17 (current) — TASKS.md batch + cruft cleanup
- [ ] when hovering the cursor over a song's rating field in the tracklist view it should light up 5 dots. Whichever song is currently hovered over there should show 5 dots and if I click on one it rates it that many stars. And if it already has stars it should only show dots where there aren't stars (so if it's rated 4 stars then there's a dot on the right of the 4 stars). I can click on already rated songs to set the rating.
Plan reference: `~/.claude/plans/can-you-take-a-drifting-sonnet.md`.
Tests land in `tests/test_round17.py`.
- [ ] move search to the right of library and the browse comes up to be in line with the top of the playlist list
### Phase 1 — cruft & correctness quick wins
- [x] 1a. Tag-write filter: `_write_track_tags` only writes fields in
`tagging.EDITABLE_FIELDS`; rating/size edits become library-JSON-only
and no longer rewrite music files (`library_manager.py`)
- [x] 1b. Replace `print()` with logging; new `tag_write_failed` signal
surfaced in the status bar (`library_manager.py`, `file_importer.py`,
`main_window.py`, `main.py`)
- [x] 1c. O(n²) import fix: `{location: track}` index per `import_paths` +
cached `_max_track_id` for O(1) `new_track_id` (`file_importer.py`,
`library_manager.py`)
- [x] 1d. `TrackTableModel._row_by_id` index → O(1) `refresh_track` /
`reveal_track` (`track_table.py`)
- [x] 1e. `threading.Lock` around the scrobble-queue load/mutate/save
(`lastfm.py`)
- [x] 1f. Last.fm login: prefs write marshalled to the GUI thread via an
internal signal (`lastfm.py`)
- [x] 1g. Shared `read_json`/`write_json` in `json_storage.py`; drop the
duplicates in `conflict_resolver.py`
- [x] 1h. `lastfm._call`: try JSON first, fall back to `raise_for_status()`
on non-JSON bodies
- [~] 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. _(Round 14 — Phase 1 done: full iTunes import (incl. nested groups, which parse + evaluate) and a flat-rule editor. **Phase 2 TODO:** nested-group editing UI in the dialog — until then, imported nested playlists are shown read-only. See tasks-done.md.)_
### Phases 24 — player & desktop integration
- [x] Task F: Bluetooth zero-volume after pause/resume — factored
`_apply_volume()`; re-applied on resume, after `setDevice`, and on
`BufferedMedia` (`player.py`)
- [ ] **verify Task F on the Bluetooth machine**: pause ≥30 s on BT
headphones, resume, must be audible without seeking
- [x] Task E: exit segfault — idempotent `Player.shutdown()`
(stop → clear source → detach buffer/audio outputs → disconnect
QMediaDevices), called from `closeEvent` + `aboutToQuit`.
Verified 2026-07-02: scripted run (import → play to end → close)
exits 0, no segfault.
- [x] Task C: MPRIS play/pause commandeering — root cause found & fixed:
`_notify` sent `PropertiesChanged` with `invalidated_properties`
marshalled as `av` instead of `as`, so gsd-media-keys dropped it and
never bumped LinTunes in its media-key MRU. Now an explicit empty
string-array via `QDBusArgument` (`mpris.py`). Verified 2026-07-02 by
D-Bus loopback: old code's signature was `sa{sv}av`, new is `sa{sv}as`
(test_round17).
- [ ] **verify Task C in practice**: play in LinTunes with a stale YouTube
tab around; the media key should control only LinTunes. Fallbacks if
GNOME still misroutes (documented, not built):
`org.gnome.SettingsDaemon.MediaKeys.GrabMediaPlayerKeys`,
bus-name re-registration on play.
- [ ] 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?
### Phases 57 — features
- [x] Task B: search bar moved into the Library header strip
(`_top_strip`), directly right of the sidebar Library button, so the
browse/tracklist top aligns with the playlist tree (`library_view.py`)
- [x] Task A: rating hover dots — hovering a rating cell shows 5 slots
(★ where rated, • where not); click slot k sets k stars; clicking the
current rating clears it. `RatingDelegate` + `rating_edited` signal,
wired to `manager.edit_track_fields` in both views (`track_table.py`,
`library_view.py`, `playlist_view.py`). Rating is library-only (no
music-file rewrite), undoable with Ctrl+Z.
- [x] Task D: right-click "Download Album Art…" — iTunes Search API
(no key; upscale artworkUrl100 → 600x600), off-thread fetch,
confirmation dialog with preview + Next Result, embed via
`tagging.write_artwork` + size refresh, invalidate MPRIS art cache
(new `art_search.py`, new `gui/album_art_dialog.py`,
`track_table.py`, `main_window.py`, `mpris.py`)
- [ ] verify Tasks A/B/D by eye in the running app (hover/click ratings,
search-bar alignment, art download on a real album)
- [ ] ability to right-click a song and download album art. Is there a repo we can search to get it? Wikipedia? It should ask for confirmation the art is correct.
### Phase 8
- [x] `TableSettingsMixin`: dedupe sort/columns/width persistence between
LibraryView and PlaylistView (new `gui/table_settings.py`)
- [ ] Minor: LinTunes segfaults during Qt-Multimedia/FFmpeg pipeline teardown on
app **exit** (fires after the GUI is gone — cosmetic, no data risk since writes
are atomic). Tidy the shutdown so the media objects are released cleanly.
## Standalone maintenance (manual, not code work)
- [ ] Live-sync v2 (Round 16 shipped v1): per-playlist **tombstones** so a
deletion made on one machine while the other edits the same playlist isn't
resurrected by the union merge; optional **per-change accept/refuse** review
(v1 offers whole-file restore only). Also consider a lighter per-file reload for
very large libraries (v1 does a full reload + smart recompute on each external
change).
- [ ] Artwork recovery against the LIVE data dir (embeds the 790
album-propagated covers; gets art-bearing coverage to 100%):
1. `python3 scripts/audit_artwork.py --data-dir <data>` (read-only re-check)
2. `python3 scripts/recover_artwork.py --data-dir <data> --dry-run` → review
3. re-run with `--write`; `audit_artwork.py` again to confirm
Note: `lintunes/itc.py` is a live dependency of `recover_artwork.py`
(tests in `test_round9.py`) — do not delete as "unused".
## Parked / deferred
- [ ] (`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). -->
- [ ] Make the data dir location a **preference** (currently only settable via
`--data-dir` / `--save-config`).
Artwork migration still to run against the live library (before relying on it):
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.
## done
- [x] confirm last fm works
- [x] do do a volume slider, this can some space between the visualizer and the timeline.
- [x] 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
- [x] 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.
- [x] 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.
- [x] **Move data dir** to `/run/media/trav/tummult/music/lintunes/` — done
2026-07-01 (fresh import written there, config saved with `--save-config`).
Track paths are now stored **relative to the data dir** (`lintunes/paths.py`,
applied at the `json_storage` boundary) so the Syncthing-shared library is
portable to the second machine regardless of its mount point. See tasks-done.md
Round 15.
- [x] Run the real import on trav's library — done 2026-07-01: 21,382 tracks,
462 playlists + 21 folders, 18 smart playlists (3 kept as snapshot), 5 missing
files, 1,324 case/unicode path fixes. Imported to the synced data dir; old
`./data` kept as a rollback backup until machine 2 is confirmed.
- [ ] Smart playlists Phase 2: nested-group editing UI in the criteria
dialog (import + evaluation of nested groups already works; imported
nested playlists are read-only until then). Big change, on hold.
- [ ] Live-sync v2: per-playlist tombstones (so a delete on one machine
isn't resurrected by the union merge during a simultaneous edit) +
optional per-change accept/refuse review. Low priority — trav doesn't
edit on both machines at once; v1 handles sequential use.
- [ ] Data-dir location as an in-app preference (currently only
`--data-dir` / `--save-config`).
- [ ] Album-art grid view: a browsable grid of album covers for the
library (the artwork-coverage investigation that motivated it is
answered & tooled — see Standalone maintenance above).
- [ ] Off-thread artwork reads: `SidebarArt.set_track` and the Get Info
dialog read embedded art synchronously on the GUI thread (fine for
local files; worth revisiting for large FLACs).
- [ ] Progress UI (or background thread) for multi-selection tag writes in
`edit_tracks_fields` — a big batch currently blocks the UI.
- [ ] More aggressive media-key commandeering beyond the Task C fix, if the
real-machine verification shows GNOME still routing keys elsewhere.

95
lintunes/art_search.py Normal file
View File

@ -0,0 +1,95 @@
"""Album-art lookup via the iTunes Search API (no API key required).
Pure parsing/URL helpers are separated from the network calls so they can be
tested offline; ``AlbumArtFetcher`` runs the whole search+download on a
daemon thread (the lastfm pattern) and reports back over a Qt signal, which
is delivered queued on the GUI thread.
"""
import threading
from dataclasses import dataclass
from PyQt6.QtCore import QObject, pyqtSignal
ITUNES_SEARCH_URL = "https://itunes.apple.com/search"
ART_SIZE = 600 # px; the API hands out 100x100 URLs that scale on request
TIMEOUT_S = 15
@dataclass
class ArtCandidate:
artist: str
album: str
art_url: str # already upgraded to ART_SIZE
def upgrade_artwork_url(url: str, size: int = ART_SIZE) -> str:
"""The API returns .../100x100bb.jpg thumbnails; Apple's image server
serves the same asset at (almost) any requested size."""
return url.replace("100x100", f"{size}x{size}")
def parse_results(payload: dict) -> list[ArtCandidate]:
candidates = []
for item in payload.get("results", []):
url = item.get("artworkUrl100")
if not url:
continue
candidates.append(ArtCandidate(
artist=item.get("artistName", ""),
album=item.get("collectionName", ""),
art_url=upgrade_artwork_url(url),
))
return candidates
def search_album_art(artist: str, album: str, limit: int = 5) -> list[ArtCandidate]:
import requests
response = requests.get(
ITUNES_SEARCH_URL,
params={"term": f"{artist} {album}".strip(), "entity": "album",
"media": "music", "limit": limit},
timeout=TIMEOUT_S,
)
response.raise_for_status()
return parse_results(response.json())
def fetch_image(url: str) -> tuple[bytes, str]:
"""Download an image; returns (bytes, mime type)."""
import requests
response = requests.get(url, timeout=TIMEOUT_S)
response.raise_for_status()
mime = response.headers.get("Content-Type", "image/jpeg").split(";")[0]
return response.content, mime or "image/jpeg"
class AlbumArtFetcher(QObject):
"""One search-or-download running off the GUI thread.
``search_finished`` carries {"candidates": [ArtCandidate, ...]} or
{"error": str}; ``image_finished`` carries {"candidate": ArtCandidate,
"image": bytes, "mime": str} or {"error": str}.
"""
search_finished = pyqtSignal(object)
image_finished = pyqtSignal(object)
def search(self, artist: str, album: str):
def work():
try:
candidates = search_album_art(artist, album)
self.search_finished.emit({"candidates": candidates})
except Exception as e:
self.search_finished.emit({"error": str(e)})
threading.Thread(target=work, daemon=True).start()
def fetch(self, candidate: ArtCandidate):
def work():
try:
data, mime = fetch_image(candidate.art_url)
self.image_finished.emit(
{"candidate": candidate, "image": data, "mime": mime})
except Exception as e:
self.image_finished.emit({"error": str(e)})
threading.Thread(target=work, daemon=True).start()

View File

@ -0,0 +1,105 @@
"""Confirmation dialog for downloaded album art.
Shows one iTunes Search result at a time (300px preview + "Artist — Album"),
with Next Result cycling through the other matches. Nothing is written until
the user accepts; the caller then embeds ``selected_image``/``selected_mime``.
"""
from PyQt6.QtWidgets import (
QDialog, QDialogButtonBox, QLabel, QPushButton, QVBoxLayout,
)
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QPixmap
from lintunes.art_search import AlbumArtFetcher
PREVIEW_PX = 300
class AlbumArtDialog(QDialog):
def __init__(self, candidates, track_count: int, parent=None):
super().__init__(parent)
self.setWindowTitle("Download Album Art")
self._candidates = candidates
self._index = 0
self._fetcher = AlbumArtFetcher()
self._fetcher.image_finished.connect(self._on_image)
self.selected_image: bytes | None = None
self.selected_mime = "image/jpeg"
self._image: bytes | None = None
self._mime = "image/jpeg"
layout = QVBoxLayout(self)
self._preview = QLabel()
self._preview.setFixedSize(PREVIEW_PX, PREVIEW_PX)
self._preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._preview.setStyleSheet("border: 1px solid palette(mid);")
layout.addWidget(self._preview, alignment=Qt.AlignmentFlag.AlignHCenter)
self._title = QLabel()
self._title.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self._title.setWordWrap(True)
layout.addWidget(self._title)
noun = "track" if track_count == 1 else "tracks"
layout.addWidget(QLabel(f"The artwork will be embedded in "
f"{track_count} {noun}."),
alignment=Qt.AlignmentFlag.AlignHCenter)
buttons = QDialogButtonBox()
self._use_btn = QPushButton("Use This Artwork")
self._use_btn.setDefault(True)
buttons.addButton(self._use_btn, QDialogButtonBox.ButtonRole.AcceptRole)
self._next_btn = QPushButton("Next Result")
self._next_btn.setEnabled(len(candidates) > 1)
buttons.addButton(self._next_btn, QDialogButtonBox.ButtonRole.ActionRole)
buttons.addButton(QDialogButtonBox.StandardButton.Cancel)
buttons.accepted.connect(self._accept_current)
buttons.rejected.connect(self.reject)
self._next_btn.clicked.connect(self._next_candidate)
layout.addWidget(buttons)
self._load_current()
# ---- candidate cycling ----
def _load_current(self):
candidate = self._candidates[self._index]
n = len(self._candidates)
counter = f" ({self._index + 1}/{n})" if n > 1 else ""
self._title.setText(
f"<b>{candidate.artist}{candidate.album}</b>{counter}")
self._image = None
self._use_btn.setEnabled(False)
self._preview.setText("Loading…")
self._preview.setPixmap(QPixmap())
self._fetcher.fetch(candidate)
def _next_candidate(self):
self._index = (self._index + 1) % len(self._candidates)
self._load_current()
def _on_image(self, result: dict):
# Ignore a stale download finishing after Next Result moved on.
if result.get("candidate") is not self._candidates[self._index]:
if "candidate" in result:
return
if "error" in result:
self._preview.setText(f"Couldn't load image:\n{result['error']}")
return
pixmap = QPixmap()
if not pixmap.loadFromData(result["image"]):
self._preview.setText("Couldn't decode image")
return
self._image = result["image"]
self._mime = result["mime"]
self._preview.setPixmap(pixmap.scaled(
self._preview.size(), Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation))
self._use_btn.setEnabled(True)
def _accept_current(self):
if self._image is None:
return
self.selected_image = self._image
self.selected_mime = self._mime
self.accept()

View File

@ -6,6 +6,7 @@ from PyQt6.QtWidgets import (
)
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
from lintunes.gui.table_settings import TableSettingsMixin
from lintunes.gui.track_table import TrackTableView
@ -86,7 +87,7 @@ class _BrowserList(QListWidget):
return item.text()
class LibraryView(QWidget):
class LibraryView(TableSettingsMixin, QWidget):
"""All-tracks view with the iTunes-style 3-column browser (Ctrl+B)."""
play_requested = pyqtSignal(list, int)
@ -101,23 +102,24 @@ class LibraryView(QWidget):
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Empty strip matching the sidebar's Library button / playlist name
# Header strip matching the sidebar's Library button / playlist name
# band, so the browser+tracklist top aligns with the playlist tree.
# It hosts the search box, which thereby sits directly right of the
# Library button across the splitter.
self._top_strip = QWidget()
layout.addWidget(self._top_strip)
# Search row: a debounced filter box occupying ~1/3 of the width, top
# Search: 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)
strip_row = QHBoxLayout(self._top_strip)
strip_row.setContentsMargins(4, 2, 4, 2)
strip_row.addStretch(2)
strip_row.addWidget(self._search, 1)
self._search_timer = QTimer(self)
self._search_timer.setSingleShot(True)
@ -152,9 +154,11 @@ class LibraryView(QWidget):
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._wire_table_settings()
# Undoable; rating isn't a file tag, so this only touches the library.
self.table.rating_edited.connect(
lambda tid, rating: manager.edit_track_fields(
tid, {"rating": rating}))
manager.track_updated.connect(self.table.model_.refresh_track)
manager.track_fields_edited.connect(self._on_track_fields_edited)
@ -165,25 +169,19 @@ class LibraryView(QWidget):
# 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)
# Keep the search box inside the strip at small UI scales.
self._search.setMaximumHeight(max(16, height - 4))
# ---- settings persistence ----
# ---- settings persistence (TableSettingsMixin hooks) ----
@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 _current_settings(self):
return self._settings
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
def _mark_settings_dirty(self):
self._manager.mark_library_settings_dirty()
# ---- browser ----

View File

@ -8,10 +8,12 @@ from PyQt6.QtWidgets import (
from PyQt6.QtCore import Qt, QEvent, QTimer
from PyQt6.QtGui import QAction, QKeySequence
from lintunes import theme
from lintunes import mpris, tagging, theme
from lintunes.art_search import AlbumArtFetcher
from lintunes.inhibit import SleepInhibitor
from lintunes.player import Player
from lintunes.importers import file_importer
from lintunes.gui.album_art_dialog import AlbumArtDialog
from lintunes.gui.sidebar import SidebarPanel
from lintunes.gui.library_view import LibraryView
from lintunes.gui.playlist_view import PlaylistView
@ -86,6 +88,7 @@ class MainWindow(QMainWindow):
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)
view.table.download_art_requested.connect(self._download_album_art)
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))
@ -94,6 +97,9 @@ class MainWindow(QMainWindow):
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)
manager.tag_write_failed.connect(
lambda name, err: self.statusBar().showMessage(
f"Couldn't write tags to “{name}”: {err}", 8000))
manager.library_reloaded.connect(self._on_library_reloaded)
manager.conflict_resolved.connect(self._on_conflict_resolved)
prefs.changed.connect(self._on_prefs_changed)
@ -309,6 +315,93 @@ class MainWindow(QMainWindow):
def _import_files_to_playlist(self, paths, pid):
self.import_files([Path(p) for p in paths], pid)
# ---- album art download ----
def _download_album_art(self, track_ids: list[int]):
"""Fetch cover art from the iTunes Search API for the selection,
grouped per album, each behind its own confirmation dialog."""
groups: dict[tuple, dict] = {}
skipped = 0
for tid in track_ids:
track = self._manager.library.tracks.get(tid)
if track is None:
continue
artist = track.album_artist or track.artist
if not artist or not track.album:
skipped += 1
continue
key = (artist.casefold(), track.album.casefold())
group = groups.setdefault(
key, {"artist": artist, "album": track.album, "tracks": []})
group["tracks"].append(track)
if skipped:
self.statusBar().showMessage(
f"Skipped {skipped} track(s) with no artist/album to search by",
6000)
self._art_groups = list(groups.values())
self._art_next_group()
def _art_next_group(self):
if not self._art_groups:
return
group = self._art_groups.pop(0)
self.statusBar().showMessage(
f"Searching artwork for {group['artist']}{group['album']}")
# Kept as an attribute so the fetcher (and its thread's signal source)
# outlives this method.
self._art_fetcher = AlbumArtFetcher()
self._art_fetcher.search_finished.connect(
lambda result, g=group: self._on_art_search(g, result))
self._art_fetcher.search(group["artist"], group["album"])
def _on_art_search(self, group, result):
if "error" in result:
self.statusBar().showMessage(
f"Artwork search failed: {result['error']}", 6000)
elif not result["candidates"]:
self.statusBar().showMessage(
f"No artwork found for {group['artist']}{group['album']}",
6000)
else:
self.statusBar().clearMessage()
dialog = AlbumArtDialog(result["candidates"],
len(group["tracks"]), self)
if dialog.exec() and dialog.selected_image:
self._embed_album_art(group["tracks"], dialog.selected_image,
dialog.selected_mime)
self._art_next_group()
def _embed_album_art(self, tracks, image: bytes, mime: str):
"""Embed confirmed artwork into each track's file. Like the Info
dialog, the artwork write itself is not undoable; the size refresh is
an in-memory update (size isn't a tag field)."""
failures = []
done = 0
for track in tracks:
if not track.location:
continue
try:
tagging.write_artwork(track.location, image, mime)
except Exception as e:
failures.append(f"{track.name}: {e}")
continue
done += 1
mpris.invalidate_artwork(track)
try:
size = Path(track.location).stat().st_size
except OSError:
continue
self._manager.update_track_fields(track.track_id, {"size": size})
if failures:
QMessageBox.warning(
self, "Download Album Art",
"Couldn't embed artwork in some files:\n\n"
+ "\n".join(failures[:10]))
elif done:
noun = "track" if done == 1 else "tracks"
self.statusBar().showMessage(
f"Artwork embedded in {done} {noun}", 5000)
# ---- relocating a moved/renamed file ----
def _existing_ancestor(self, path: str) -> str:
@ -433,5 +526,6 @@ class MainWindow(QMainWindow):
def closeEvent(self, event):
self._inhibitor.release()
self.player.shutdown()
self._manager.flush()
super().closeEvent(event)

View File

@ -1,11 +1,12 @@
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QApplication
from PyQt6.QtCore import pyqtSignal
from lintunes.gui.table_settings import TableSettingsMixin
from lintunes.gui.track_table import TrackTableView, parse_tracks_mime
from lintunes.gui.playlist_ops import add_tracks_with_dup_check
class PlaylistView(QWidget):
class PlaylistView(TableSettingsMixin, QWidget):
play_requested = pyqtSignal(list, int)
info_requested = pyqtSignal(list)
files_dropped = pyqtSignal(list, str) # paths, playlist pid
@ -33,15 +34,17 @@ class PlaylistView(QWidget):
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._wire_table_settings()
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.cut_requested.connect(self._on_cut)
self.table.paste_requested.connect(self._on_paste)
# Undoable; rating isn't a file tag, so this only touches the library.
self.table.rating_edited.connect(
lambda tid, rating: manager.edit_track_fields(
tid, {"rating": rating}))
manager.playlist_content_changed.connect(self._on_content_changed)
manager.track_updated.connect(self.table.model_.refresh_track)
@ -93,26 +96,12 @@ class PlaylistView(QWidget):
else:
self._reload_tracks()
# ---- settings persistence ----
# ---- settings persistence (TableSettingsMixin hooks) ----
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 _current_settings(self):
return self._playlist.settings if self._playlist else None
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
def _mark_settings_dirty(self):
self._manager.mark_playlist_settings_dirty(self._playlist.persistent_id)
# ---- content edits ----

View File

@ -0,0 +1,38 @@
"""Shared persistence of per-view table settings (sort, columns, widths).
LibraryView and PlaylistView both mirror TrackTableView's header signals into
a PlaylistSettings and mark it dirty; only where the settings live differs.
Subclasses implement ``_current_settings()`` (None when nothing is shown, e.g.
the playlist view before a playlist is opened) and ``_mark_settings_dirty()``.
"""
class TableSettingsMixin:
def _wire_table_settings(self):
"""Connect the table's header signals; call once self.table exists."""
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)
def _on_sort_changed(self, field, ascending):
settings = self._current_settings()
if settings is None:
return
settings.sort_column = field
settings.sort_ascending = ascending
self._mark_settings_dirty()
def _on_columns_changed(self, columns):
settings = self._current_settings()
if settings is None:
return
settings.visible_columns = columns
self.table.apply_settings(settings)
self._mark_settings_dirty()
def _on_width_changed(self, field, width):
settings = self._current_settings()
if settings is None:
return
settings.column_widths[field] = width
self._mark_settings_dirty()

View File

@ -3,10 +3,12 @@ import os
from PyQt6.QtWidgets import (
QTableView, QAbstractItemView, QMenu, QApplication, QProxyStyle, QStyle,
QStyledItemDelegate, QStyleOptionViewItem,
)
from PyQt6.QtCore import (
Qt, QAbstractTableModel, QModelIndex, QSortFilterProxyModel, pyqtSignal,
QMimeData, QUrl, QPoint, QPointF, QRectF, QMetaType, QTimer,
Qt, QAbstractTableModel, QModelIndex, QPersistentModelIndex,
QSortFilterProxyModel, pyqtSignal,
QMimeData, QUrl, QPoint, QPointF, QRect, QRectF, QMetaType, QTimer,
)
from PyQt6.QtGui import (
QDrag, QKeySequence, QShortcut, QPixmap, QPainter, QPen, QPolygonF, QColor,
@ -117,6 +119,82 @@ def _format_rating(rating: int) -> str:
return "" * (rating // 20)
# ---- rating column interaction (hover dots + click to rate) ----
RATING_LEFT_PAD = 2 # px before the first of the five fixed rating slots
def rating_slot_width(fm: QFontMetrics) -> int:
"""Width of one rating slot, from the view's font so the painted glyphs
and the click hit-testing stay aligned at any UI scale."""
return max(fm.horizontalAdvance(""), fm.horizontalAdvance("")) + 4
def rating_slot_at(x: int, slot_w: int) -> int:
"""Which slot (1..5) an x offset within the rating cell lands on."""
if slot_w <= 0:
return 1
return max(1, min(5, (x - RATING_LEFT_PAD) // slot_w + 1))
def rating_from_click(current: int, slot: int) -> int:
"""New 0-100 rating for a click on slot 1..5. Clicking the slot equal to
the current star count clears the rating (iTunes behavior — and the only
way to un-rate); anything else sets slot*20."""
new = slot * 20
return 0 if new == current else new
class RatingDelegate(QStyledItemDelegate):
"""Paints the rating column as five fixed slots: ★ for each rated star
and, on the hovered cell, • for each empty slot — so the whole 1-5 range
is clickable at a glance. Installed table-wide; every other column is
painted by the default delegate."""
def __init__(self, view):
super().__init__(view)
self._view = view
def paint(self, painter, option, index):
model = self._view.model_
if model.fields[index.column()] != "rating":
super().paint(painter, option, index)
return
opt = QStyleOptionViewItem(option)
self.initStyleOption(opt, index)
opt.text = "" # base item = themed background/selection only
style = opt.widget.style() if opt.widget else QApplication.style()
style.drawControl(QStyle.ControlElement.CE_ItemViewItem, opt,
painter, opt.widget)
track = model.track_at(self._view.proxy.mapToSource(index).row())
stars = max(0, min(5, (track.rating or 0) // 20))
hovered = self._view.is_rating_hovered(index)
if not stars and not hovered:
return
if opt.state & QStyle.StateFlag.State_Selected:
base = opt.palette.highlightedText().color()
else:
base = opt.palette.text().color()
dot = QColor(base)
dot.setAlpha(110)
slot_w = rating_slot_width(QFontMetrics(opt.font))
painter.save()
painter.setFont(opt.font)
for slot in range(1, 6):
cell = QRect(opt.rect.left() + RATING_LEFT_PAD + (slot - 1) * slot_w,
opt.rect.top(), slot_w, opt.rect.height())
if slot <= stars:
painter.setPen(base)
painter.drawText(cell, Qt.AlignmentFlag.AlignCenter, "")
elif hovered:
painter.setPen(dot)
painter.drawText(cell, Qt.AlignmentFlag.AlignCenter, "")
painter.restore()
def _format_size(size: int) -> str:
if size <= 0:
return ""
@ -216,6 +294,7 @@ class TrackTableModel(QAbstractTableModel):
def __init__(self, parent=None, with_index_column=False):
super().__init__(parent)
self._tracks: list[Track] = []
self._rows_by_id: dict[int, list[int]] = {}
self._fields: list[str] = []
self._with_index = with_index_column
self._now_playing_id: int | None = None
@ -246,6 +325,11 @@ class TrackTableModel(QAbstractTableModel):
def set_tracks(self, tracks: list[Track]):
self.beginResetModel()
self._tracks = list(tracks)
# A track can appear multiple times in a playlist, so map each id to
# all of its rows.
self._rows_by_id = {}
for row, track in enumerate(self._tracks):
self._rows_by_id.setdefault(track.track_id, []).append(row)
self.endResetModel()
def track_at(self, row: int) -> Track:
@ -255,11 +339,14 @@ class TrackTableModel(QAbstractTableModel):
def tracks(self) -> list[Track]:
return self._tracks
def row_for_id(self, track_id: int) -> int | None:
rows = self._rows_by_id.get(track_id)
return rows[0] if rows else None
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))
for row in self._rows_by_id.get(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
@ -337,6 +424,8 @@ class TrackTableView(QTableView):
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
rating_edited = pyqtSignal(int, int) # track_id, new rating 0-100
download_art_requested = pyqtSignal(list) # selected track ids
def __init__(self, parent=None, playlist_mode=False):
super().__init__(parent)
@ -377,6 +466,12 @@ class TrackTableView(QTableView):
self.customContextMenuRequested.connect(self._show_context_menu)
self.doubleClicked.connect(self._on_double_click)
# Rating column: hover shows the five clickable slots, click rates.
self.setItemDelegate(RatingDelegate(self))
self.setMouseTracking(True)
self.viewport().setMouseTracking(True)
self._hover_rating_index: QPersistentModelIndex | None = None
# 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.
@ -428,17 +523,15 @@ class TrackTableView(QTableView):
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
src_row = self.model_.row_for_id(track_id)
if src_row is None:
return False
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
def set_tracks(self, tracks: list[Track]):
self.model_.set_tracks(tracks)
@ -584,11 +677,12 @@ class TrackTableView(QTableView):
paste_action = menu.addAction("Paste\tCtrl+V")
locations = self.selected_locations()
reveal_action = copy_path_action = None
reveal_action = copy_path_action = download_art_action = None
if locations:
menu.addSeparator()
reveal_action = menu.addAction("Reveal in File Browser")
copy_path_action = menu.addAction("Copy File Path")
download_art_action = menu.addAction("Download Album Art…")
# "Show in Playlist" — only for a single track, listing the regular
# playlists it belongs to. Hovering reveals the submenu; choosing an
@ -622,11 +716,60 @@ class TrackTableView(QTableView):
reveal_paths(locations)
elif chosen is copy_path_action:
QApplication.clipboard().setText("\n".join(locations))
elif download_art_action is not None and chosen is download_art_action:
self.download_art_requested.emit(self.selected_track_ids())
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())
# ---- rating hover / click ----
def _is_rating_index(self, index) -> bool:
return (index.isValid()
and self.model_.fields[index.column()] == "rating")
def is_rating_hovered(self, index) -> bool:
hover = self._hover_rating_index
return (hover is not None and hover.isValid()
and hover.row() == index.row()
and hover.column() == index.column())
def _set_rating_hover(self, index):
"""Track the hovered rating cell, repainting the old and new cells."""
new = QPersistentModelIndex(index) if index is not None else None
old = self._hover_rating_index
if new == old:
return
self._hover_rating_index = new
for persistent in (old, new):
if persistent is not None and persistent.isValid():
self.viewport().update(self.visualRect(
self.proxy.index(persistent.row(), persistent.column())))
def mouseMoveEvent(self, event):
index = self.indexAt(event.position().toPoint())
self._set_rating_hover(index if self._is_rating_index(index) else None)
super().mouseMoveEvent(event)
def leaveEvent(self, event):
self._set_rating_hover(None)
super().leaveEvent(event)
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
pos = event.position().toPoint()
index = self.indexAt(pos)
if self._is_rating_index(index):
track = self.model_.track_at(self.proxy.mapToSource(index).row())
slot = rating_slot_at(
pos.x() - self.visualRect(index).left(),
rating_slot_width(QFontMetrics(self.font())))
self.rating_edited.emit(
track.track_id, rating_from_click(track.rating or 0, slot))
return # consume: a rating click never selects or starts a drag
super().mousePressEvent(event)
# ---- playback ----
def _on_double_click(self, index):

View File

@ -1,3 +1,4 @@
import logging
import re
import shutil
from datetime import datetime, timezone
@ -6,6 +7,8 @@ from pathlib import Path
from lintunes.models import Track
from lintunes import tagging
log = logging.getLogger(__name__)
AUDIO_EXTENSIONS = {".mp3", ".m4a", ".flac", ".aac", ".wav", ".aiff", ".aif"}
@ -38,28 +41,40 @@ def unique_path(dest: Path) -> Path:
raise FileExistsError(f"Could not find a free name for {dest}")
def import_file(source: Path, music_dir: Path, manager) -> Track | None:
def location_index(manager) -> dict[Path, Track]:
"""{location path: track} over the whole library, so bulk imports dedup
with one scan instead of one per file."""
return {Path(t.location): t
for t in manager.library.tracks.values() if t.location}
def import_file(source: Path, music_dir: Path, manager,
by_location: dict[Path, Track] | None = None) -> 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."""
file is already in the library. ``by_location`` lets bulk callers share
one prebuilt (and maintained) dedup index."""
source = Path(source).resolve()
if not source.is_file() or not is_audio_file(source):
return None
fields = tagging.read_tags(source)
if by_location is None:
by_location = location_index(manager)
# 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
existing = by_location.get(source)
if existing is not None:
return existing
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
existing = by_location.get(dest)
if existing is not None:
return existing
dest = unique_path(dest)
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, dest)
@ -77,6 +92,7 @@ def import_file(source: Path, music_dir: Path, manager) -> Track | None:
if key in fields:
setattr(track, key, fields[key])
manager.add_track(track)
by_location[Path(track.location)] = track
return track
@ -92,11 +108,12 @@ def import_paths(paths: list[Path], music_dir: Path, manager,
files.append(path)
imported = []
by_location = location_index(manager)
for file_path in files:
try:
track = import_file(file_path, music_dir, manager)
track = import_file(file_path, music_dir, manager, by_location)
except Exception as e:
print(f"Failed to import {file_path}: {e}")
log.warning("Failed to import %s: %s", file_path, e)
continue
if track:
imported.append(track)

View File

@ -35,11 +35,16 @@ def sign(params: dict, secret: str) -> str:
class LastFm(QObject):
login_finished = pyqtSignal(bool, str) # success, message
status_message = pyqtSignal(str)
# Worker thread → GUI thread: prefs must only be touched from the GUI
# thread (writing them emits prefs.changed, which drives theme updates).
_session_ready = pyqtSignal(str, str, str, str) # key, secret, user, session
def __init__(self, prefs, data_dir: Path, parent=None):
super().__init__(parent)
self._prefs = prefs
self._queue_path = Path(data_dir) / QUEUE_FILE
self._queue_lock = threading.Lock()
self._session_ready.connect(self._store_session)
# Retry anything left over from previous runs
if self.is_logged_in() and self._load_queue():
self._spawn(self._flush_queue)
@ -63,16 +68,19 @@ class LastFm(QObject):
{"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)}")
self._session_ready.emit(
api_key, api_secret, session.get("name", username),
session["key"])
except Exception as e:
self.login_finished.emit(False, f"Login failed: {e}")
self._spawn(work)
def _store_session(self, api_key, api_secret, username, session_key):
self._prefs.update_lastfm(
api_key=api_key, api_secret=api_secret,
username=username, session_key=session_key)
self.login_finished.emit(True, f"Logged in as {username}")
def logout(self):
self._prefs.update_lastfm(session_key="", scrobble_enabled=False)
@ -120,7 +128,12 @@ class LastFm(QObject):
self.status_message.emit(f"Scrobble queued (offline?): {e}")
def _flush_queue(self):
queue = self._load_queue()
# Take the whole queue under the lock; entries that still fail are
# re-queued afterwards so a concurrent scrobble can't be clobbered by
# our read-modify-write.
with self._queue_lock:
queue = self._load_queue()
self._save_queue([])
if not queue:
return
remaining = []
@ -133,7 +146,10 @@ class LastFm(QObject):
self._call_with_session("track.scrobble", params)
except Exception:
remaining.append(entry)
self._save_queue(remaining)
if remaining:
with self._queue_lock:
merged = remaining + self._load_queue()
self._save_queue(merged[-QUEUE_LIMIT:])
flushed = len(queue) - len(remaining)
if flushed:
self.status_message.emit(f"Sent {flushed} queued scrobble(s)")
@ -161,7 +177,13 @@ class LastFm(QObject):
payload["api_sig"] = sign(payload, secret)
payload["format"] = "json"
response = requests.post(API_URL, data=payload, timeout=15)
data = response.json()
try:
data = response.json()
except ValueError:
# Non-JSON body (e.g. an HTML 5xx page): surface the HTTP error.
response.raise_for_status()
raise RuntimeError(
f"Unexpected response from last.fm ({response.status_code})")
if "error" in data:
raise RuntimeError(data.get("message", f"error {data['error']}"))
response.raise_for_status()
@ -170,9 +192,10 @@ class LastFm(QObject):
# ---- offline queue ----
def _enqueue(self, entry: dict):
queue = self._load_queue()
queue.append(entry)
self._save_queue(queue[-QUEUE_LIMIT:])
with self._queue_lock:
queue = self._load_queue()
queue.append(entry)
self._save_queue(queue[-QUEUE_LIMIT:])
def _load_queue(self) -> list:
if not self._queue_path.exists():

View File

@ -1,9 +1,12 @@
import logging
import secrets
from datetime import datetime, timezone
from pathlib import Path
from PyQt6.QtCore import QObject, QTimer, pyqtSignal
log = logging.getLogger(__name__)
from lintunes import smart, tagging
from lintunes.models import Library, Playlist, PlaylistType
from lintunes.storage import json_storage
@ -32,6 +35,7 @@ class LibraryManager(QObject):
track_fields_edited = pyqtSignal(int, list) # track_id, changed field names
library_reloaded = pyqtSignal() # library re-read from disk (sync)
conflict_resolved = pyqtSignal(list) # list[ConflictSummary] just merged
tag_write_failed = pyqtSignal(str, str) # track name, error text
def __init__(self, library: Library, data_dir: Path, parent=None):
super().__init__(parent)
@ -65,10 +69,12 @@ class LibraryManager(QObject):
self._own_sigs: dict[str, tuple | None] = {}
self.snapshot_sync_state()
self._max_track_id = max(self.library.tracks.keys(), default=0)
# ---- id generation ----
def new_track_id(self) -> int:
return max(self.library.tracks.keys(), default=0) + 1
return self._max_track_id + 1
def new_persistent_id(self) -> str:
while True:
@ -312,6 +318,7 @@ class LibraryManager(QObject):
if not track.persistent_id:
track.persistent_id = secrets.token_hex(8).upper()
self.library.tracks[track.track_id] = track
self._max_track_id = max(self._max_track_id, track.track_id)
self._dirty_tracks = True
self._dirty_metadata = True
self._schedule_save()
@ -403,13 +410,18 @@ class LibraryManager(QObject):
for tid, new, _old in changes]))
def _write_track_tags(self, track, field_map: dict) -> bool:
if not track.location:
# Library-only fields (rating, size, start/stop times) have no tag
# representation; don't rewrite the audio file for them.
writable = {k: v for k, v in field_map.items()
if k in tagging.EDITABLE_FIELDS}
if not writable or not track.location:
return True
try:
tagging.write_tags(track.location, field_map)
tagging.write_tags(track.location, writable)
return True
except Exception as e:
print(f"Could not write tags to {track.location}: {e}")
log.warning("Could not write tags to %s: %s", track.location, e)
self.tag_write_failed.emit(track.name or track.location, str(e))
return False
def _apply_track_fields(self, track_id: int, field_map: dict):
@ -653,6 +665,8 @@ class LibraryManager(QObject):
self.library.import_date = disk.import_date
self.library.library_settings = disk.library_settings
self._max_track_id = max(self.library.tracks.keys(), default=0)
self._recomputing = True
try:
self.recompute_all_smart()

View File

@ -94,6 +94,9 @@ def run_import(xml_path: Path, music_root: str | None, data_dir: Path):
def run_gui(data_dir: Path, files: list[Path], qt_args: list[str] | None = None):
import logging
logging.basicConfig(level=logging.INFO)
from PyQt6.QtWidgets import QApplication, QMessageBox, QFontDialog
from lintunes import theme
@ -129,6 +132,10 @@ def run_gui(data_dir: Path, files: list[Path], qt_args: list[str] | None = None)
window = MainWindow(manager, prefs, lastfm)
app.installEventFilter(window)
mpris = setup_mpris(window.player, window) # noqa: F841 — keep alive
# Tear the multimedia pipeline down before Qt destroys objects (exit
# segfault in the FFmpeg backend otherwise); shutdown() is idempotent so
# the closeEvent path double-calling it is fine.
app.aboutToQuit.connect(window.player.shutdown)
app.aboutToQuit.connect(manager.flush)
# Watch the data dir so another machine's synced-in changes appear live.

View File

@ -8,9 +8,10 @@ is exported to a small cache dir so GNOME's media popup can show it
import os
from pathlib import Path
from PyQt6.QtCore import QObject, pyqtSlot, pyqtProperty, pyqtClassInfo
from PyQt6.QtCore import QMetaType, QObject, pyqtSlot, pyqtProperty, pyqtClassInfo
from PyQt6.QtDBus import (
QDBusConnection, QDBusAbstractAdaptor, QDBusObjectPath, QDBusMessage,
QDBusConnection, QDBusAbstractAdaptor, QDBusArgument, QDBusObjectPath,
QDBusMessage,
)
from lintunes import tagging
@ -45,6 +46,17 @@ def export_artwork(track) -> str | None:
return "file://" + str(target)
def invalidate_artwork(track):
"""Drop a track's cached art so the next export re-reads the file (call
after embedding new artwork)."""
if track is None or not track.persistent_id:
return
try:
(_art_cache_dir() / f"{track.persistent_id}.jpg").unlink()
except OSError:
pass
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)]:
@ -246,7 +258,20 @@ class MprisPlayerAdaptor(QDBusAbstractAdaptor):
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)
QDBusConnection.sessionBus().send(build_properties_changed(changed))
def build_properties_changed(changed: dict) -> QDBusMessage:
"""A spec-correct Properties.PropertiesChanged signal for the Player
interface. invalidated_properties must be marshalled as a D-Bus string
array ("as"); a plain Python [] goes over as "av", and GDBus consumers —
including gsd-media-keys, which keys its media-key MRU on seeing
PlaybackStatus change — validate the signature and drop the signal.
(Same pitfall as reveal_paths in track_table.)"""
msg = QDBusMessage.createSignal(
OBJECT_PATH, "org.freedesktop.DBus.Properties", "PropertiesChanged")
invalidated = QDBusArgument()
invalidated.beginArray(QMetaType(QMetaType.Type.QString.value).id())
invalidated.endArray()
msg.setArguments(["org.mpris.MediaPlayer2.Player", changed, invalidated])
return msg

View File

@ -74,6 +74,27 @@ class Player(QObject):
self._media.mediaStatusChanged.connect(self._on_media_status)
self._media.errorOccurred.connect(self._on_error)
self._shutdown_done = False
def shutdown(self):
"""Tear down the Qt Multimedia pipeline in a safe order before Qt
destroys the objects. Without this, QObject children are destroyed in
creation order at exit — the QAudioOutput dies while the QMediaPlayer
(FFmpeg backend) still references it, which segfaults after the GUI is
gone. Idempotent; wired to both closeEvent and aboutToQuit."""
if self._shutdown_done:
return
self._shutdown_done = True
try:
self._media_devices.audioOutputsChanged.disconnect(
self._on_audio_outputs_changed)
except TypeError:
pass
self._media.stop()
self._media.setSource(QUrl()) # release the demux/decode pipeline
self._media.setAudioBufferOutput(None)
self._media.setAudioOutput(None) # detach the sink while both live
# ---- queue ----
def play_queue(self, track_ids: list[int], start_index: int):
@ -148,8 +169,15 @@ class Player(QObject):
than jumping to "loud" in the first few percent."""
level = max(0.0, min(1.0, float(level)))
self._volume = level
self._apply_volume()
def _apply_volume(self):
"""Push the stored logical volume to the sink. Called again on resume
and after device swaps: some backends (seen with Bluetooth sinks)
re-create the sink with a stale/zero gain, which played silently until
a seek re-primed it."""
self._audio.setVolume(QAudio.convertVolume(
level,
self._volume,
QAudio.VolumeScale.LogarithmicVolumeScale,
QAudio.VolumeScale.LinearVolumeScale,
))
@ -164,6 +192,7 @@ class Player(QObject):
if self.is_playing():
self._media.pause()
elif self._current_track is not None:
self._apply_volume()
self._media.play()
elif self._queue:
self._index = max(self._index, 0)
@ -267,6 +296,7 @@ class Player(QObject):
return
was_playing = self.is_playing()
self._audio.setDevice(new_default)
self._apply_volume() # the new sink may come up at a stale/zero gain
# Some backends stall the sink across a device swap; nudge it back.
# play() is a no-op if playback actually continued.
if was_playing:
@ -284,6 +314,9 @@ class Player(QObject):
self._pending_start_ms = 0
self._media.setPosition(start)
return
if status == QMediaPlayer.MediaStatus.BufferedMedia:
self._apply_volume() # sink can be re-created per source
return
if status == QMediaPlayer.MediaStatus.EndOfMedia:
self._note_finished(self._current_track)
# The player has already stopped at EndOfMedia, so force autoplay

View File

@ -1,10 +1,11 @@
import json
import re
import shutil
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from lintunes.storage.json_storage import read_json, write_json
# Syncthing conflict file pattern: filename.sync-conflict-20240101-123456-ABCDEFG.json
CONFLICT_PATTERN = re.compile(r"^(.+)\.sync-conflict-(\d{8}-\d{6})-\w+(\.\w+)$")
@ -107,8 +108,8 @@ def _find_conflict_files(data_dir: Path) -> list[tuple[Path, Path]]:
def _merge_library(original_path: Path, conflict_path: Path) -> ConflictSummary:
original = _load_json(original_path)
conflict = _load_json(conflict_path)
original = read_json(original_path)
conflict = read_json(conflict_path)
added = 0
changed = 0
@ -125,7 +126,7 @@ def _merge_library(original_path: Path, conflict_path: Path) -> ConflictSummary:
name = original[tid].get("name") or f"track {tid}"
examples.append(f"{name}”: {notes[0]}")
_save_json(original_path, original)
write_json(original_path, original)
lines = []
if changed:
@ -187,8 +188,8 @@ def _merge_track_fields(orig: dict, conflict: dict) -> list[str]:
def _merge_playlist(original_path: Path, conflict_path: Path) -> ConflictSummary:
original = _load_json(original_path)
conflict = _load_json(conflict_path)
original = read_json(original_path)
conflict = read_json(conflict_path)
name = original.get("name") or conflict.get("name") or original_path.stem
# Smart playlists: membership is derived, so keep the newer criteria and let
@ -216,7 +217,7 @@ def _merge_playlist(original_path: Path, conflict_path: Path) -> ConflictSummary
original["track_ids"] = orig_ids + extra
order_src = "this machine"
_save_json(original_path, original)
write_json(original_path, original)
added = len(set(original["track_ids"]) - orig_set)
lines = [f"Order kept from {order_src} (most recently edited)."]
@ -252,15 +253,3 @@ def _stars(rating) -> str:
def _copy(src: Path, dest: Path):
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
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):
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)

View File

@ -32,7 +32,7 @@ def save_tracks(library: Library, data_dir: Path):
# across machines that sync the folder to different mount points.
data["location"] = to_relative(data["location"], data_dir)
tracks_dict[str(tid)] = data
_write_json(data_dir / "library.json", tracks_dict)
write_json(data_dir / "library.json", tracks_dict)
def save_metadata(library: Library, data_dir: Path):
@ -44,13 +44,13 @@ def save_metadata(library: Library, data_dir: Path):
"playlist_count": len(library.playlists),
"library_settings": library.library_settings.to_dict(),
}
_write_json(data_dir / "library_metadata.json", metadata)
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())
write_json(playlists_dir / f"{playlist.persistent_id}.json", playlist.to_dict())
def delete_playlist_file(persistent_id: str, data_dir: Path):
@ -67,7 +67,7 @@ def load_library(data_dir: Path) -> Library:
tracks = {}
library_path = data_dir / "library.json"
if library_path.exists():
tracks_dict = _read_json(library_path)
tracks_dict = read_json(library_path)
for tid_str, track_data in tracks_dict.items():
track = Track.from_dict(track_data)
# Stored relative to the data dir; resolve back to an absolute path.
@ -80,7 +80,7 @@ def load_library(data_dir: Path) -> Library:
library_settings = PlaylistSettings()
metadata_path = data_dir / "library_metadata.json"
if metadata_path.exists():
metadata = _read_json(metadata_path)
metadata = read_json(metadata_path)
music_folder = metadata.get("music_folder", "")
import_date = metadata.get("import_date")
if "library_settings" in metadata:
@ -91,7 +91,7 @@ def load_library(data_dir: Path) -> Library:
playlists_dir = data_dir / "playlists"
if playlists_dir.exists():
for playlist_file in playlists_dir.glob("*.json"):
playlist_data = _read_json(playlist_file)
playlist_data = read_json(playlist_file)
playlist = Playlist.from_dict(playlist_data)
playlists[playlist.persistent_id] = playlist
@ -104,13 +104,13 @@ def load_library(data_dir: Path) -> Library:
)
def _write_json(path: Path, data: dict):
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:
def read_json(path: Path) -> dict:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)

View File

@ -1,5 +1,23 @@
## Done
### Misc (recorded 2026-07-02, moved from TASKS.md during the Round 17 rewrite)
- [x] Confirm Last.fm scrobbling works
- [x] Volume slider between the visualizer and the timeline
- [x] Preferences: per-area color adjustments (background, button
round-rects, now-playing text + its backing, tracklist stripe gray), each
with a live black↔white slider
- [x] Visualizer: discrete light-gray-on-light-gray mode
- [x] Cut/copy/paste tracks (Ctrl+X/C/V); paste inserts above the selected
track, not at the end
- [x] Auto-scroll the track list when dragging a track above/below the
visible area
- [x] Move data dir to `/run/media/trav/tummult/music/lintunes/` +
relative track paths (see Round 15)
- [x] Real library import: 21,382 tracks, 462 playlists + 21 folders,
18 smart playlists (3 kept as snapshot), 5 missing files, 1,324
case/unicode path fixes (see Round 15)
### Round 16 (2026-07-02) — Live multi-machine sync + conflict alerts
Both machines now reflect each other's changes within a couple seconds, with

393
tests/test_round17.py Normal file
View File

@ -0,0 +1,393 @@
"""Round 17: cruft cleanup + rating clicks, search-bar move, player shutdown,
volume re-apply, MPRIS marshalling fix, album-art download plumbing."""
import threading
from unittest.mock import MagicMock, patch
from PyQt6.QtCore import QCoreApplication, Qt, QPoint, QPointF, QUrl
from PyQt6.QtGui import QFontMetrics, QMouseEvent
from lintunes import player as player_module
from lintunes import tagging
from lintunes.lastfm import LastFm
from lintunes.library_manager import LibraryManager
from lintunes.models import Track
from lintunes.models.library import Library
from lintunes.player import Player
def _manager(tmp_path, tracks=()):
library = Library()
for track in tracks:
library.tracks[track.track_id] = track
return LibraryManager(library, tmp_path)
# ---- 1a: tag-write filter (rating/size edits never rewrite music files) ----
class TestTagWriteFilter:
def test_rating_edit_succeeds_without_file(self, qapp, tmp_path):
track = Track(track_id=1, name="Song",
location=str(tmp_path / "missing.mp3"))
manager = _manager(tmp_path, [track])
manager.edit_track_fields(1, {"rating": 80})
assert track.rating == 80
manager.undo_stack.undo()
assert track.rating == 0
def test_rating_edit_leaves_file_untouched(self, qapp, tmp_path, mp3_file):
track = Track(track_id=1, name="Song", location=str(mp3_file))
manager = _manager(tmp_path, [track])
before = mp3_file.stat().st_mtime_ns
manager.edit_track_fields(1, {"rating": 60})
assert track.rating == 60
assert mp3_file.stat().st_mtime_ns == before
def test_mixed_edit_still_writes_tags(self, qapp, tmp_path, mp3_file):
track = Track(track_id=1, name="Old", location=str(mp3_file))
manager = _manager(tmp_path, [track])
manager.edit_track_fields(1, {"name": "New", "rating": 40})
assert track.name == "New" and track.rating == 40
assert tagging.read_tags(mp3_file)["name"] == "New"
def test_failed_tag_write_emits_signal(self, qapp, tmp_path, monkeypatch):
track = Track(track_id=1, name="Song", location=str(tmp_path / "x.mp3"))
manager = _manager(tmp_path, [track])
failures = []
manager.tag_write_failed.connect(lambda name, err: failures.append(name))
def boom(path, fields):
raise OSError("disk full")
monkeypatch.setattr("lintunes.tagging.write_tags", boom)
manager.edit_track_fields(1, {"name": "New"})
assert failures == ["Song"]
assert track.name == "Song" # edit not applied when the write failed
# ---- 1c: import dedup index + cached max track id ----
class TestImportIndex:
def test_duplicate_import_returns_existing(self, qapp, tmp_path, mp3_file):
from lintunes.importers.file_importer import import_paths
manager = _manager(tmp_path)
music = tmp_path / "music"
first = import_paths([mp3_file], music, manager)
again = import_paths([mp3_file], music, manager)
assert len(manager.library.tracks) == 1
assert first[0].track_id == again[0].track_id
def test_new_track_id_tracks_additions(self, qapp, tmp_path):
manager = _manager(tmp_path, [Track(track_id=7, name="A")])
assert manager.new_track_id() == 8
manager.add_track(Track(name="B"))
assert manager.new_track_id() == 9
def test_new_track_id_recomputed_on_reload(self, qapp, tmp_path):
from lintunes.storage import json_storage
manager = _manager(tmp_path, [Track(track_id=1, name="A")])
manager.flush()
# Another machine synced in a track with a higher id.
disk = Library()
disk.tracks[1] = Track(track_id=1, name="A")
disk.tracks[42] = Track(track_id=42, name="B")
json_storage.save_tracks(disk, tmp_path)
manager.reload_from_disk()
assert manager.new_track_id() == 43
# ---- 1d: model id→row index ----
class TestModelRowIndex:
def test_refresh_and_row_for_id(self, qapp):
from lintunes.gui.track_table import TrackTableModel
model = TrackTableModel()
a, b = Track(track_id=1, name="A"), Track(track_id=2, name="B")
model.set_tracks([a, b, a]) # duplicate row, as in a playlist
assert model.row_for_id(1) == 0
assert model.row_for_id(2) == 1
assert model.row_for_id(99) is None
changed = []
model.dataChanged.connect(lambda tl, br: changed.append(tl.row()))
model.refresh_track(1)
assert changed == [0, 2] # both occurrences repainted
# ---- 1e: scrobble queue lock ----
class TestScrobbleQueue:
def test_concurrent_enqueue_keeps_all(self, qapp, tmp_path):
prefs = MagicMock()
prefs.lastfm = {}
lastfm = LastFm(prefs, tmp_path)
threads = [threading.Thread(
target=lastfm._enqueue, args=({"artist": "A", "track": str(i),
"timestamp": i},))
for i in range(20)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(lastfm._load_queue()) == 20
# ---- 1f: login prefs write marshalled to the GUI thread ----
class TestLoginMarshal:
def test_login_updates_prefs_on_gui_thread(self, qapp, tmp_path, monkeypatch):
prefs = MagicMock()
prefs.lastfm = {}
lastfm = LastFm(prefs, tmp_path)
monkeypatch.setattr(
LastFm, "_call",
staticmethod(lambda *a, **k: {"session": {"name": "trav",
"key": "sk123"}}))
results = []
lastfm.login_finished.connect(lambda ok, msg: results.append((ok, msg)))
lastfm.login("key", "secret", "trav", "pw")
for _ in range(100):
QCoreApplication.processEvents()
if results:
break
threading.Event().wait(0.02)
assert results and results[0][0] is True
prefs.update_lastfm.assert_called_once_with(
api_key="key", api_secret="secret", username="trav",
session_key="sk123")
# ---- Task C: MPRIS PropertiesChanged marshalled as "as" ----
class TestMprisSignal:
def test_signal_marshals_as_sa_sv_as(self, qapp):
"""Send the signal over the real session bus and check the signature
it goes out with. A plain [] marshals invalidated_properties as "av"
and GDBus consumers (gsd-media-keys) validate + drop the signal; the
spec requires "as"."""
import pytest
from PyQt6.QtDBus import QDBusConnection
from lintunes.mpris import OBJECT_PATH, build_properties_changed
from PyQt6.QtCore import QObject, pyqtSlot
from PyQt6.QtDBus import QDBusMessage
class Receiver(QObject):
def __init__(self):
super().__init__()
self.messages = []
@pyqtSlot(QDBusMessage)
def on_signal(self, msg):
self.messages.append(msg)
bus = QDBusConnection.sessionBus()
if not bus.isConnected():
pytest.skip("no D-Bus session bus in this environment")
receiver = Receiver()
assert bus.connect(
"", OBJECT_PATH, "org.freedesktop.DBus.Properties",
"PropertiesChanged", receiver.on_signal)
assert bus.send(build_properties_changed(
{"PlaybackStatus": "Playing"}))
for _ in range(100):
QCoreApplication.processEvents()
if receiver.messages:
break
threading.Event().wait(0.02)
assert receiver.messages, "signal was not delivered back over the bus"
assert receiver.messages[0].signature() == "sa{sv}as"
def test_invalidate_artwork_removes_cache_file(self, tmp_path, monkeypatch):
from lintunes import mpris
monkeypatch.setattr(mpris, "_art_cache_dir", lambda: tmp_path)
cached = tmp_path / "ABCD1234.jpg"
cached.write_bytes(b"old art")
mpris.invalidate_artwork(Track(track_id=1, persistent_id="ABCD1234"))
assert not cached.exists()
# Missing file / no persistent id are silent no-ops.
mpris.invalidate_artwork(Track(track_id=1, persistent_id="ABCD1234"))
mpris.invalidate_artwork(None)
# ---- Tasks E + F: player shutdown order and volume re-apply ----
def _mock_player(qapp, tmp_path):
manager = _manager(tmp_path)
with patch.multiple(player_module,
QMediaPlayer=MagicMock(),
QAudioOutput=MagicMock(),
QAudioBufferOutput=MagicMock(),
QMediaDevices=MagicMock()):
player = Player(manager)
return player
class TestPlayerShutdown:
def test_shutdown_detaches_in_order_and_is_idempotent(self, qapp, tmp_path):
player = _mock_player(qapp, tmp_path)
player.shutdown()
player._media.stop.assert_called_once()
player._media.setSource.assert_called_once_with(QUrl())
player._media.setAudioBufferOutput.assert_called_with(None)
player._media.setAudioOutput.assert_called_with(None)
player.shutdown() # second call must be a no-op
player._media.stop.assert_called_once()
class TestVolumeReapply:
def test_set_volume_applies_converted_gain(self, qapp, tmp_path):
from PyQt6.QtMultimedia import QAudio
player = _mock_player(qapp, tmp_path)
player.set_volume(0.5)
expected = QAudio.convertVolume(
0.5, QAudio.VolumeScale.LogarithmicVolumeScale,
QAudio.VolumeScale.LinearVolumeScale)
player._audio.setVolume.assert_called_with(expected)
def test_resume_reapplies_volume(self, qapp, tmp_path, monkeypatch):
player = _mock_player(qapp, tmp_path)
player._current_track = Track(track_id=1, name="A")
applied = []
monkeypatch.setattr(player, "_apply_volume",
lambda: applied.append(True))
monkeypatch.setattr(player, "is_playing", lambda: False)
player.toggle_play() # paused with a cued track → resume
assert applied == [True]
player._media.play.assert_called_once()
# ---- Task B: search bar lives in the header strip ----
class TestSearchBarPlacement:
def test_search_parent_is_top_strip(self, qapp, tmp_path):
from lintunes.gui.library_view import LibraryView
manager = _manager(tmp_path, [Track(track_id=1, name="A")])
view = LibraryView(manager)
assert view._search.parentWidget() is view._top_strip
view.set_header_height(34)
assert view._top_strip.height() >= 0 # metrics path still works
def test_search_still_filters(self, qapp, tmp_path):
from lintunes.gui.library_view import LibraryView
manager = _manager(tmp_path, [
Track(track_id=1, name="Apple Song", artist="X"),
Track(track_id=2, name="Banana Song", artist="Y")])
view = LibraryView(manager)
view._search.setText("banana")
view._apply_search()
assert [t.name for t in view.table.model_.tracks] == ["Banana Song"]
# ---- Task A: rating hover dots + click to rate ----
class TestRatingHelpers:
def test_rating_from_click(self):
from lintunes.gui.track_table import rating_from_click
assert rating_from_click(0, 3) == 60 # set 3 stars
assert rating_from_click(60, 5) == 100 # raise to 5
assert rating_from_click(60, 3) == 0 # click current count clears
assert rating_from_click(100, 1) == 20 # lower to 1
def test_rating_slot_at_bounds(self):
from lintunes.gui.track_table import rating_slot_at
assert rating_slot_at(0, 14) == 1
assert rating_slot_at(-5, 14) == 1 # left edge clamps
assert rating_slot_at(3 + 14 * 2, 14) == 3
assert rating_slot_at(9999, 14) == 5 # right edge clamps
assert rating_slot_at(10, 0) == 1 # degenerate width
class TestRatingClicks:
def _view(self, qapp, tracks):
from lintunes.gui.track_table import TrackTableView
from lintunes.models.playlist import PlaylistSettings
view = TrackTableView(playlist_mode=False)
view.apply_settings(PlaylistSettings(
visible_columns=["name", "rating"], sort_column="name"))
view.set_tracks(tracks)
view.resize(500, 300)
return view
def test_click_on_rating_cell_emits(self, qapp):
from lintunes.gui.track_table import (
RATING_LEFT_PAD, rating_slot_width)
track = Track(track_id=1, name="A", rating=40)
view = self._view(qapp, [track])
emitted = []
view.rating_edited.connect(lambda tid, r: emitted.append((tid, r)))
col = view.model_.fields.index("rating")
rect = view.visualRect(view.proxy.index(0, col))
slot_w = rating_slot_width(QFontMetrics(view.font()))
# Click the 4th slot → 80.
x = rect.left() + RATING_LEFT_PAD + int(slot_w * 3.5)
pos = QPointF(x, rect.center().y())
event = QMouseEvent(QMouseEvent.Type.MouseButtonPress, pos,
Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier)
view.mousePressEvent(event)
assert emitted == [(1, 80)]
def test_library_view_wiring_edits_and_undoes(self, qapp, tmp_path):
from lintunes.gui.library_view import LibraryView
track = Track(track_id=1, name="A")
manager = _manager(tmp_path, [track])
view = LibraryView(manager)
view.table.rating_edited.emit(1, 80)
assert track.rating == 80
manager.undo_stack.undo()
assert track.rating == 0
def test_hover_state_tracks_cell(self, qapp):
track = Track(track_id=1, name="A", rating=40)
view = self._view(qapp, [track])
col = view.model_.fields.index("rating")
index = view.proxy.index(0, col)
view._set_rating_hover(index)
assert view.is_rating_hovered(index)
assert not view.is_rating_hovered(view.proxy.index(0, 0))
view._set_rating_hover(None)
assert not view.is_rating_hovered(index)
# ---- Task D: album art search plumbing (offline) ----
class TestArtSearch:
def test_upgrade_artwork_url(self):
from lintunes.art_search import upgrade_artwork_url
url = "https://is1.mzstatic.com/image/thumb/x/100x100bb.jpg"
assert "600x600bb.jpg" in upgrade_artwork_url(url)
assert "100x100" not in upgrade_artwork_url(url)
def test_parse_results(self):
from lintunes.art_search import parse_results
payload = {"results": [
{"artistName": "Artist", "collectionName": "Album",
"artworkUrl100": "https://x/100x100bb.jpg"},
{"artistName": "No Art"}, # skipped: no artworkUrl100
]}
candidates = parse_results(payload)
assert len(candidates) == 1
assert candidates[0].artist == "Artist"
assert candidates[0].album == "Album"
assert "600x600" in candidates[0].art_url
def test_embed_updates_file_and_size(self, qapp, tmp_path, mp3_file,
jpeg_bytes):
# The Task D accept path: write_artwork + size refresh, no undo entry.
track = Track(track_id=1, name="A", location=str(mp3_file), size=1)
manager = _manager(tmp_path, [track])
tagging.write_artwork(track.location, jpeg_bytes, "image/jpeg")
manager.update_track_fields(1, {"size": mp3_file.stat().st_size})
assert tagging.read_embedded_artwork(track.location) == jpeg_bytes
assert track.size == mp3_file.stat().st_size
assert not manager.undo_stack.can_undo()
# ---- context menu exposes the new action's signal ----
class TestDownloadArtSignal:
def test_signal_exists_and_carries_ids(self, qapp):
from lintunes.gui.track_table import TrackTableView
view = TrackTableView()
got = []
view.download_art_requested.connect(got.append)
view.download_art_requested.emit([1, 2])
assert got == [[1, 2]]