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:
171
TASKS.md
171
TASKS.md
@ -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 2–4 — 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 5–7 — 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
95
lintunes/art_search.py
Normal 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()
|
||||
105
lintunes/gui/album_art_dialog.py
Normal file
105
lintunes/gui/album_art_dialog.py
Normal 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()
|
||||
@ -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 ----
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 ----
|
||||
|
||||
38
lintunes/gui/table_settings.py
Normal file
38
lintunes/gui/table_settings.py
Normal 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()
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
393
tests/test_round17.py
Normal 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]]
|
||||
Reference in New Issue
Block a user