diff --git a/CLAUDE.md b/CLAUDE.md index 6c66810..dc9fe78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -481,6 +481,18 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal `syncthing` run a real handshake with `pytest --syncthing`. The service starts from `run_gui`, never from constructing `MainWindow` — tests build windows by the dozen and must not reach a real Syncthing. + **Friend mode** (`gui/friend_mode.py`, entered from the ▾ beside Library) + shows a friend's `library.json` through a second `LibraryView` over + `cassette/friend_library.FriendSource` — the slice of `LibraryManager` that + view reads, read-only. Their track ids are *theirs*: the table is + `set_read_only(True)` (no play, no drag, no copy into my playlists, only + "lookup on youtube"), and the cassette column is `track_table.CASSETTE_FIELD` + (registered in `COLUMN_MAP`, deliberately not `ALL_COLUMNS`), live only + where `cassette_hooks` is set. Nothing is written until Save and Close + (`service.save_requests` → `requests.json`); any other exit with changes + asks "Save changes?". `cassette/matching.py` is best effort (exact vs + fuzzy); `cassette/space.py` judges the music disk (red) and Syncthing's + `minDiskFree` floor on the cassette disk (orange) separately. - **`lintunes/cast/`** — Chromecast playback (Connections menu), using the **media-receiver model**: `server.py` runs a `ThreadingHTTPServer` on an diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 384e710..1a0b003 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.28.0" +__version__ = "0.29.0" diff --git a/lintunes/cassette/friend_library.py b/lintunes/cassette/friend_library.py new file mode 100644 index 0000000..a4f29a9 --- /dev/null +++ b/lintunes/cassette/friend_library.py @@ -0,0 +1,97 @@ +"""A friend's shared library, read from their folder, for browsing. + +``load_friend_library`` turns their ``library.json`` / ``playlists.json`` +(written by their ``share.py``) into ``Track`` objects and a simple playlist +list — **their** track ids, never mixed into mine. The files may be missing +(nothing synced yet), half-synced or garbled: every failure is an empty +library, never a crash. + +``FriendSource`` is the small slice of ``LibraryManager`` that ``LibraryView`` +reads, over that library and read-only: edits are dropped, settings aren't +saved anywhere, and there are no playlists of mine to "show in". +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from PyQt6.QtCore import QObject, pyqtSignal + +from lintunes.cassette import publish, share +from lintunes.models import Track +from lintunes.models.library import Library +from lintunes.models.playlist import PlaylistSettings + +CASSETTE_FIELD = "cassette" +FRIEND_COLUMNS = [CASSETTE_FIELD, "name", "total_time", "artist", "album", + "genre", "year"] + + +@dataclass +class FriendPlaylist: + pid: str + name: str + track_ids: list = field(default_factory=list) + smart: bool = False + + +@dataclass +class FriendLibrary: + name: str = "" + tracks: dict = field(default_factory=dict) # their id -> Track + playlists: list = field(default_factory=list) # FriendPlaylist, by name + + def playlist(self, pid: str) -> FriendPlaylist | None: + return next((p for p in self.playlists if p.pid == pid), None) + + +def load_friend_library(in_dir: Path, fallback_name: str = "") -> FriendLibrary: + intro = publish.read_intro(in_dir) + result = FriendLibrary(name=str(intro.get("name") or fallback_name)) + data = publish.read_json(in_dir / share.LIBRARY_FILE, {}) + for entry in (data.get("tracks", []) if isinstance(data, dict) else []): + if not isinstance(entry, dict) or not isinstance(entry.get("id"), int): + continue + fields = {k: v for k, v in entry.items() if k in share.SHARED_FIELDS} + try: + track = Track.from_dict(fields) + except TypeError: + continue + track.track_id = entry["id"] + result.tracks[track.track_id] = track + data = publish.read_json(in_dir / share.PLAYLISTS_FILE, {}) + for entry in (data.get("playlists", []) if isinstance(data, dict) else []): + if not isinstance(entry, dict) or not entry.get("id"): + continue + ids = [t for t in entry.get("track_ids", []) + if isinstance(t, int) and t in result.tracks] + result.playlists.append(FriendPlaylist( + pid=str(entry["id"]), name=str(entry.get("name") or "Untitled"), + track_ids=ids, smart=bool(entry.get("smart")))) + result.playlists.sort(key=lambda p: p.name.casefold()) + return result + + +class FriendSource(QObject): + """What LibraryView needs from a manager, over a friend's library.""" + + track_updated = pyqtSignal(int) + track_fields_edited = pyqtSignal(int, list) + + def __init__(self, tracks: dict, parent=None): + super().__init__(parent) + self.library = Library(tracks=dict(tracks)) + self.library.library_settings = PlaylistSettings( + visible_columns=list(FRIEND_COLUMNS), sort_column="artist") + + def set_tracks(self, tracks: dict): + self.library.tracks = dict(tracks) + + def playlists_containing(self, track_id): + return [] + + def edit_track_fields(self, *args, **kwargs): + pass # read-only + + def mark_library_settings_dirty(self): + pass # column choices in friend mode live for the visit only diff --git a/lintunes/cassette/matching.py b/lintunes/cassette/matching.py new file mode 100644 index 0000000..0a2661f --- /dev/null +++ b/lintunes/cassette/matching.py @@ -0,0 +1,95 @@ +"""Do I already have this song? Best effort — the user is the final judge. + +* **exact**: normalized artist, album and title all match and the durations + are within ~2 s. Friend mode can hide these ("Hide tracks I have"). +* **fuzzy**: normalized artist and title match (a different album, a + different length). Shown light grey, never hidden: a live take or a + remaster is worth a second look, not a silent skip. + +Normalizing folds case and accents, turns punctuation into spaces, drops a +featured-artist credit ("feat.", "ft.", "featuring", "with" in brackets) and a +leading "The", so "The Beatles — Hey Jude (Remastered)" and "beatles - hey +jude" land together for the fuzzy check. The artist key prefers album artist, +the same spelling the browser files a song under. +""" +from __future__ import annotations + +import re +import unicodedata + +EXACT = "exact" +FUZZY = "fuzzy" +DURATION_SLACK_MS = 2000 + +_FEAT_BRACKET = re.compile( + r"[\(\[]\s*(feat\.?|ft\.?|featuring|with)\s[^\)\]]*[\)\]]", re.IGNORECASE) +_FEAT_TAIL = re.compile(r"\s+(feat\.?|ft\.?|featuring)\s+.*$", re.IGNORECASE) +_EDITION = re.compile( + r"[\(\[][^\)\]]*\b(remaster(ed)?|deluxe|edition|version|mono|stereo|" + r"bonus track)\b[^\)\]]*[\)\]]", re.IGNORECASE) +_LEADING_THE = re.compile(r"^the\s+") +_PUNCT = re.compile(r"[^\w\s]", re.UNICODE) +_SPACE = re.compile(r"\s+") + + +def normalize(text: str) -> str: + text = text or "" + text = _FEAT_BRACKET.sub(" ", text) + text = _FEAT_TAIL.sub("", text) + text = _EDITION.sub(" ", text) + text = unicodedata.normalize("NFKD", text) + text = "".join(c for c in text if not unicodedata.combining(c)) + text = text.casefold().replace("&", " and ") + text = _PUNCT.sub(" ", text) + text = _SPACE.sub(" ", text).strip() + return _LEADING_THE.sub("", text) + + +def _artist(track) -> str: + return getattr(track, "album_artist", "") or getattr(track, "artist", "") + + +def _keys(track) -> tuple[str, str, str]: + """(artist, album, title), normalized. A track's own artist is tried too + (see LibraryIndex), since one side may have an album artist and the + other not.""" + return (normalize(_artist(track)), normalize(getattr(track, "album", "")), + normalize(getattr(track, "name", ""))) + + +class LibraryIndex: + """My library, indexed once per friend-mode visit.""" + + def __init__(self, tracks): + self._exact: dict[tuple, list[int]] = {} + self._fuzzy: set[tuple] = set() + for track in tracks: + title = normalize(track.name) + album = normalize(track.album) + for artist in self._artists(track): + self._exact.setdefault((artist, album, title), []).append( + track.total_time or 0) + self._fuzzy.add((artist, title)) + + @staticmethod + def _artists(track) -> set[str]: + names = {normalize(track.artist), normalize(track.album_artist)} + names.discard("") + return names or {""} + + def classify(self, track) -> str | None: + """EXACT, FUZZY or None for a friend's track.""" + title = normalize(track.name) + if not title: + return None + album = normalize(track.album) + duration = track.total_time or 0 + fuzzy = False + for artist in self._artists(track): + for mine in self._exact.get((artist, album, title), ()): + if not duration or not mine or \ + abs(mine - duration) <= DURATION_SLACK_MS: + return EXACT + if (artist, title) in self._fuzzy: + fuzzy = True + return FUZZY if fuzzy else None diff --git a/lintunes/cassette/requests.py b/lintunes/cassette/requests.py new file mode 100644 index 0000000..f8c07b3 --- /dev/null +++ b/lintunes/cassette/requests.py @@ -0,0 +1,28 @@ +"""``requests.json``: the friend's track ids I want sent, in my folder to them. + +Rewritten only on Save and Close in friend mode and when pruning (a delivery +arrived, or they stopped offering something) — never as you click. +""" +from __future__ import annotations + +from pathlib import Path + +from lintunes.cassette import publish + +REQUESTS_FILE = "requests.json" + + +def write_requests(out: Path, track_ids) -> bool: + publish.prepare_out_dir(out) + return publish.write_json_if_changed(out / REQUESTS_FILE, { + "format": publish.FORMAT_VERSION, + "tracks": sorted({int(t) for t in track_ids}), + }) + + +def read_requests(folder: Path) -> set[int]: + """The ids a requests.json asks for; empty when missing or garbled.""" + data = publish.read_json(folder / REQUESTS_FILE, {}) + if not isinstance(data, dict): + return set() + return {t for t in data.get("tracks", []) if isinstance(t, int)} diff --git a/lintunes/cassette/service.py b/lintunes/cassette/service.py index ccb38f6..15ff826 100644 --- a/lintunes/cassette/service.py +++ b/lintunes/cassette/service.py @@ -237,6 +237,23 @@ class CassetteService(QObject): changed.append(token) return changed + # ---- requests ---- + + def save_requests(self, token: str, cassetted, followed, requested): + """Friend mode's Save and Close: remember what I cassetted and which + playlists I follow, and tell the friend (``requests.json`` in my + folder to them) which of their track ids to send.""" + from lintunes.cassette import requests as req + with self._lock: + friend = self._state.friends.get(token) + if friend is None: + return + friend.cassetted = sorted(int(t) for t in cassetted) + friend.followed = sorted(followed) + self._save() + req.write_requests(out_dir(self.root, token), requested) + self.changed.emit() + # ---- user actions ---- def create_invite(self) -> str: diff --git a/lintunes/cassette/space.py b/lintunes/cassette/space.py new file mode 100644 index 0000000..3f1ef71 --- /dev/null +++ b/lintunes/cassette/space.py @@ -0,0 +1,49 @@ +"""Will the songs I cassetted fit? + +Two disks matter, and they may be the same one: + +* the **music** disk, where imported songs end up — if they don't fit there, + they don't fit (**full**, red); +* the disk holding the cassette folders, where Syncthing lands each delivery + first — Syncthing stops syncing a folder once its disk drops below the + folder's ``minDiskFree`` (1 % by default), so getting close to that is + **tight** (orange): the songs would fit, but syncing would stall. +""" +from __future__ import annotations + +import shutil +from pathlib import Path + +OK = "ok" +TIGHT = "tight" +FULL = "full" + + +def verdict(need: int, music_free: int, cassette_free: int, + floor_bytes: int) -> str: + if need > music_free: + return FULL + if cassette_free - need < floor_bytes: + return TIGHT + return OK + + +def disk_usage(path: Path): + """shutil.disk_usage of the nearest existing ancestor, or None.""" + path = Path(path) + for candidate in (path, *path.parents): + if candidate.exists(): + try: + return shutil.disk_usage(candidate) + except OSError: + return None + return None + + +def human(size: int) -> str: + size = float(max(0, size)) + for unit in ("B", "KB", "MB", "GB", "TB"): + if size < 1000 or unit == "TB": + return f"{size:.0f} {unit}" if unit in ("B", "KB") else f"{size:.1f} {unit}" + size /= 1000 + return f"{size:.1f} TB" diff --git a/lintunes/gui/cassette_art.py b/lintunes/gui/cassette_art.py new file mode 100644 index 0000000..e5f74fd --- /dev/null +++ b/lintunes/gui/cassette_art.py @@ -0,0 +1,144 @@ +"""Cassette's two little pictures, painted (no image files to ship or theme). + +* The **cassette button**: a small embossed square with a tape glyph. Raised + when off; pressed in and grey when on ("I want this"); a medium grey on + hover over a pressed one, to say a click would let it go. Functionally + it's a checkbox — the tape is reserved for asking friends for songs. +* The **UFO**: a friend's playlist. Light purple when followed, a grey + outline when not. + +Plus friend mode's light purple, shared by everything that says "you're in +someone else's library". +""" +from __future__ import annotations + +from PyQt6.QtCore import QPointF, QRectF, Qt +from PyQt6.QtGui import ( + QBrush, QColor, QIcon, QLinearGradient, QPainter, QPainterPath, QPen, + QPixmap, +) + +FRIEND_PURPLE = QColor("#cdb4f2") # button tint in friend mode +FRIEND_PURPLE_DARK = QColor("#9b76d6") # its border / pressed shade +UFO_PURPLE = QColor("#b38ee8") + +OFF, ON, PARTIAL = "off", "on", "partial" + +ON_GREY = QColor("#9c9c9c") +ON_HOVER_GREY = QColor("#bdbdbd") + + +def friend_button_qss(selector: str = "QPushButton", padding: str = "3px 12px") -> str: + """Light purple, embossed like every other button.""" + light = FRIEND_PURPLE.lighter(106).name() + dark = FRIEND_PURPLE.darker(104).name() + return (f"{selector} {{ background: qlineargradient(x1:0, y1:0, x2:0, y2:1," + f" stop:0 {light}, stop:0.5 {FRIEND_PURPLE.name()}, stop:1 {dark});" + f" color: black; border: 1px solid {FRIEND_PURPLE_DARK.name()};" + f" border-radius: 4px; padding: {padding}; min-height: 0; }}" + f"{selector}:pressed {{ background: {FRIEND_PURPLE_DARK.name()}; }}") + + +def button_rect(cell: QRectF, margin: float = 2.0) -> QRectF: + """The square the button occupies inside a cell (left-aligned).""" + side = max(8.0, min(cell.height(), cell.width()) - 2 * margin) + return QRectF(cell.left() + margin, cell.top() + (cell.height() - side) / 2, + side, side) + + +def paint_cassette_button(painter: QPainter, rect: QRectF, state: str, + hovered: bool, palette) -> None: + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + base = palette.button().color() + mid = palette.mid().color() + box = QRectF(rect).adjusted(0.5, 0.5, -0.5, -0.5) + radius = max(2.0, box.width() * 0.18) + + gradient = QLinearGradient(box.topLeft(), box.bottomLeft()) + if state == OFF: + top = base.lighter(112 if hovered else 106) + gradient.setColorAt(0, top) + gradient.setColorAt(1, base.darker(106)) + else: + fill = ON_HOVER_GREY if hovered else ON_GREY + if state == PARTIAL: + fill = QColor(fill).lighter(118) + gradient.setColorAt(0, fill.darker(108)) # pressed in: lit from below + gradient.setColorAt(1, fill.lighter(106)) + painter.setBrush(QBrush(gradient)) + painter.setPen(QPen(mid, 1)) + painter.drawRoundedRect(box, radius, radius) + + ink = QColor(palette.text().color()) + if state != OFF: + ink = QColor("#303030") + ink.setAlpha(200 if state != OFF else 150) + _paint_tape(painter, box, ink, half=(state == PARTIAL)) + painter.restore() + + +def _paint_tape(painter: QPainter, box: QRectF, ink: QColor, half=False): + """A cassette: body, label window, two reels.""" + w, h = box.width(), box.height() + body = QRectF(box.left() + w * 0.16, box.top() + h * 0.28, + w * 0.68, h * 0.46) + pen = QPen(ink, max(1.0, w / 16)) + painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRoundedRect(body, body.height() * 0.15, body.height() * 0.15) + reel_r = body.height() * 0.17 + cy = body.top() + body.height() * 0.45 + for fx in (0.3, 0.7): + painter.drawEllipse(QPointF(body.left() + body.width() * fx, cy), + reel_r, reel_r) + # the bottom trapezoid where the tape shows + path = QPainterPath() + path.moveTo(body.left() + body.width() * 0.22, body.bottom()) + path.lineTo(body.left() + body.width() * 0.3, body.bottom() - body.height() * 0.22) + path.lineTo(body.left() + body.width() * 0.7, body.bottom() - body.height() * 0.22) + path.lineTo(body.left() + body.width() * 0.78, body.bottom()) + painter.drawPath(path) + if half: + painter.drawLine(QPointF(body.left() + body.width() * 0.4, cy), + QPointF(body.left() + body.width() * 0.6, cy)) + + +def paint_ufo(painter: QPainter, rect: QRectF, color: QColor, + filled: bool = True) -> None: + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + w, h = rect.width(), rect.height() + pen = QPen(color.darker(135) if filled else color, max(1.0, w / 14)) + painter.setPen(pen) + dome = QRectF(rect.left() + w * 0.32, rect.top() + h * 0.2, + w * 0.36, h * 0.4) + saucer = QRectF(rect.left() + w * 0.06, rect.top() + h * 0.38, + w * 0.88, h * 0.3) + if filled: + painter.setBrush(color.lighter(125)) + else: + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawChord(dome, 0, 180 * 16) + painter.setBrush(color if filled else Qt.BrushStyle.NoBrush) + painter.drawEllipse(saucer) + # three little lights + painter.setBrush(color.lighter(160) if filled else Qt.BrushStyle.NoBrush) + r = w * 0.045 + for fx in (0.3, 0.5, 0.7): + painter.drawEllipse(QPointF(rect.left() + w * fx, + saucer.center().y() + saucer.height() * 0.1), + r, r) + painter.restore() + + +def ufo_icon(size: int = 32, followed: bool = True, + color: QColor | None = None) -> QIcon: + pixmap = QPixmap(size, size) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + paint_ufo(painter, QRectF(1, 1, size - 2, size - 2), + color or (UFO_PURPLE if followed else QColor("#8a8a8a")), + filled=followed) + painter.end() + return QIcon(pixmap) diff --git a/lintunes/gui/cassette_ui.py b/lintunes/gui/cassette_ui.py index 0deb78a..4245dd9 100644 --- a/lintunes/gui/cassette_ui.py +++ b/lintunes/gui/cassette_ui.py @@ -9,7 +9,7 @@ from __future__ import annotations from PyQt6.QtCore import QObject, QTimer from PyQt6.QtGui import QAction -from PyQt6.QtWidgets import QDialog, QMessageBox +from PyQt6.QtWidgets import QDialog, QMenu, QMessageBox from lintunes.cassette import host from lintunes.cassette import syncthing_api as st @@ -36,6 +36,7 @@ class CassetteUi(QObject): self._actions: list[QAction] = [] self._service_factory = service_factory self.service = None + self.friend_mode = None # FriendMode while browsing a friend # ---- the service (host machine only) ---- @@ -51,6 +52,7 @@ class CassetteUi(QObject): else: from lintunes.cassette.service import CassetteService self.service = CassetteService(parent=self) + self.service.changed.connect(self._refresh_selector) self.service.notice.connect(self._on_notice) self.service.step_failed.connect(self._on_step_failed) self.service.friends_added.connect(self._republish_timer.start) @@ -84,6 +86,65 @@ class CassetteUi(QObject): self._on_step_failed("Updating what your friends can see", str(exc)) + # ---- the ▾ library selector / friend mode ---- + + def _refresh_selector(self): + """The ▾ shows once there's a friend to choose.""" + has_friends = self.service is not None and bool(self.service.state().friends) + sidebar = getattr(self._window, "sidebar", None) + if sidebar is not None and sidebar.friend_button.isHidden() == has_friends: + sidebar.set_friend_selector_visible(has_friends) + + def show_friend_menu(self): + """The ▾ was clicked. With unsaved friend-mode changes, settle those + first; then offer my library and each friend by display name.""" + if self.service is None: + return + if self.friend_mode is not None and self.friend_mode.is_dirty(): + if not self.friend_mode.confirm_leave(): + return + sidebar = self._window.sidebar + menu = QMenu(self._window) + mine = menu.addAction("My Library") + mine.setEnabled(self.friend_mode is not None and self.friend_mode.active) + menu.addSeparator() + friends = sorted(self.service.state().friends.values(), + key=lambda f: f.name.casefold()) + by_action = {} + for friend in friends: + action = menu.addAction(friend.name) + by_action[action] = friend.token + button = sidebar.friend_button + chosen = menu.exec(button.mapToGlobal(button.rect().bottomLeft())) + if chosen is None: + return + if chosen is mine: + self.leave_friend_mode() + elif chosen in by_action: + self.enter_friend_mode(by_action[chosen]) + + def enter_friend_mode(self, token: str): + from lintunes.gui.friend_mode import FriendMode + if self.friend_mode is not None and self.friend_mode.active: + if not self.friend_mode.confirm_leave(): + return + music_dir = None + if self._manager is not None: + music_dir = self._manager.organize_root() + self.friend_mode = FriendMode( + self._window, self.service, self._manager.library, + music_dir or self.service.root) + self.friend_mode.enter(token) + + def leave_friend_mode(self): + if self.friend_mode is not None and self.friend_mode.active: + self.friend_mode.confirm_leave() + + def confirm_quit(self) -> bool: + if self.friend_mode is None or not self.friend_mode.active: + return True + return self.friend_mode.confirm_leave() + def shutdown(self): if self.service is not None: self.service.stop() diff --git a/lintunes/gui/friend_mode.py b/lintunes/gui/friend_mode.py new file mode 100644 index 0000000..e3501bd --- /dev/null +++ b/lintunes/gui/friend_mode.py @@ -0,0 +1,358 @@ +"""Friend mode: browsing a friend's shared library and cassetting songs. + +Entered from the ▾ beside Library. The Library button becomes "'s +Library" in light purple, the sidebar lists their shared playlists (a UFO on +each is the follow toggle), and the content area shows their library as if +it were LinTunes on their machine — except **nothing plays** (the audio isn't +here) and nothing edits. A cassette column on the far left, and cassette +buttons on every artist and album, say "send me this". + +Nothing is saved as you go. **Save and Close** writes my requests file and +returns to my library; **Cancel** drops the changes. Leaving any other way +with unsaved changes (the ▾ again, quitting) asks "Save changes?". + +The bottom bar carries the controls for the visit: **Hide tracks I have** +(exact matches disappear; fuzzy ones are always shown, in light grey), how +much the cassetted songs weigh against free space, and the two purple +buttons. +""" +from __future__ import annotations + +from PyQt6.QtCore import QObject, Qt, pyqtSignal +from PyQt6.QtGui import QColor +from PyQt6.QtWidgets import ( + QCheckBox, QHBoxLayout, QLabel, QMessageBox, QPushButton, QVBoxLayout, + QWidget, +) + +from lintunes.cassette import matching, space +from lintunes.cassette import syncthing_api as st +from lintunes.cassette.friend_library import ( + FRIEND_COLUMNS, FriendSource, load_friend_library, +) +from lintunes.cassette.state import in_dir +from lintunes.gui import cassette_art +from lintunes.gui.cassette_art import OFF, ON, PARTIAL +from lintunes.models.playlist import PlaylistSettings + +FUZZY_GREY = QColor("#9a9a9a") +TIGHT_ORANGE = "#d9822b" +FULL_RED = "#d0342c" + +SAVE = "save" +DISCARD = "discard" +STAY = "stay" + + +def ask_save_changes(parent) -> str: + """Plain words, three answers.""" + box = QMessageBox(parent) + box.setWindowTitle("Save changes?") + box.setText("Save changes?") + box.setInformativeText("You changed which songs and playlists you want " + "from your friend.") + save = box.addButton("Save", QMessageBox.ButtonRole.AcceptRole) + discard = box.addButton("Don't Save", QMessageBox.ButtonRole.DestructiveRole) + box.addButton(QMessageBox.StandardButton.Cancel) + box.setDefaultButton(save) + box.exec() + clicked = box.clickedButton() + if clicked is save: + return SAVE + if clicked is discard: + return DISCARD + return STAY + + +class FriendPlaylistPane(QWidget): + """One of the friend's playlists, in its own order, read-only.""" + + def __init__(self, parent=None): + super().__init__(parent) + from lintunes.gui.track_table import TrackTableView + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + self.title = QLabel() + font = self.title.font() + font.setBold(True) + self.title.setFont(font) + self.title.setContentsMargins(8, 2, 8, 2) + layout.addWidget(self.title) + self.table = TrackTableView(playlist_mode=True) + self.table.set_read_only(True) + self.table.set_content_editable(False) + self.table.apply_settings(PlaylistSettings( + visible_columns=list(FRIEND_COLUMNS), sort_column="#")) + layout.addWidget(self.table) + + def show_playlist(self, name: str, tracks: list): + self.title.setText(name) + self.table.set_tracks(tracks) + + +class FriendMode(QObject): + """One visit to one friend's library.""" + + left = pyqtSignal() # back in my own library + + def __init__(self, window, service, my_library, music_dir, parent=None): + super().__init__(parent or window) + self._window = window + self._service = service + self._my_library = my_library + self._music_dir = music_dir + self.token: str | None = None + self._widgets: list[QWidget] = [] + self._view = None + self._pane = None + self._floor: tuple[float, str] | None = None + + @property + def active(self) -> bool: + return self.token is not None + + # ---- entering / leaving ---- + + def enter(self, token: str): + from lintunes.gui.library_view import LibraryView + state = self._service.state() + friend = state.friends[token] + self.token = token + self._friend_name = friend.name + self.library = load_friend_library(in_dir(self._service.root, token), + friend.name) + self.cassetted = {t for t in friend.cassetted if t in self.library.tracks} + self.followed = {p for p in friend.followed + if self.library.playlist(p) is not None} + self._saved = (set(self.cassetted), set(self.followed)) + index = matching.LibraryIndex(self._my_library.tracks.values()) + self.matches = {tid: index.classify(track) + for tid, track in self.library.tracks.items()} + self._min_disk_free(friend.in_folder) + + self._source = FriendSource(self.library.tracks, self) + self._view = LibraryView(self._source) + self._view.table.set_read_only(True) + self._view.enable_cassette(self.state_of, self.toggle) + self._view.table.model_.foreground_for = self._foreground + self._pane = FriendPlaylistPane() + self._pane.table.cassette_hooks = (lambda tid: self.state_of([tid]), + lambda tid: self._toggle_in_pane(tid)) + self._pane.table.model_.foreground_for = self._foreground + content = self._window.content_stack + content.addWidget(self._view) + content.addWidget(self._pane) + content.setCurrentWidget(self._view) + + sidebar = self._window.sidebar + sidebar.show_friend(f"{self.library.name}'s Library", + self.library.playlists, self.followed) + sidebar.friend_library_selected.connect(self._show_library) + sidebar.friend_playlist_selected.connect(self._show_playlist) + sidebar.follow_toggled.connect(self._toggle_follow) + self._build_bar() + self._refresh_bar() + + def _leave(self): + sidebar = self._window.sidebar + for signal, slot in ((sidebar.friend_library_selected, self._show_library), + (sidebar.friend_playlist_selected, self._show_playlist), + (sidebar.follow_toggled, self._toggle_follow)): + try: + signal.disconnect(slot) + except TypeError: + pass + sidebar.show_mine() + bar = self._window.statusBar() + for widget in self._widgets: + bar.removeWidget(widget) + widget.deleteLater() + self._widgets = [] + content = self._window.content_stack + for widget in (self._view, self._pane): + if widget is not None: + content.removeWidget(widget) + widget.deleteLater() + self._view = self._pane = None + self.token = None + self._window.show_my_library() + self.left.emit() + + # ---- the bottom bar ---- + + def _build_bar(self): + bar = self._window.statusBar() + self.hide_have = QCheckBox("Hide tracks I have") + self.hide_have.setToolTip( + "Hide songs that match one of yours exactly (artist, album, title " + "and length). Close matches stay, in grey.") + self.hide_have.toggled.connect(self._apply_hide) + self.size_label = QLabel() + self.save_button = QPushButton("Save and Close") + self.cancel_button = QPushButton("Cancel") + for button in (self.save_button, self.cancel_button): + # Compact: they live in the status bar. + button.setStyleSheet(cassette_art.friend_button_qss(padding="1px 10px")) + self.save_button.clicked.connect(self.save_and_close) + self.cancel_button.clicked.connect(self.cancel) + holder = QWidget() + row = QHBoxLayout(holder) + row.setContentsMargins(0, 0, 4, 0) + row.setSpacing(8) + for widget in (self.hide_have, self.size_label, self.save_button, + self.cancel_button): + row.addWidget(widget) + bar.addPermanentWidget(holder) + self._widgets = [holder] + + def _min_disk_free(self, folder_id: str): + try: + client = self._service.client() + self._floor = client.min_disk_free(folder_id) + except st.SyncthingError: + self._floor = (1.0, "%") # Syncthing's default + + def space_state(self) -> tuple[str, int, int]: + """(verdict, bytes needed, bytes free on the music disk).""" + need = sum(self.library.tracks[t].size for t in self.wanted_ids() + if t in self.library.tracks) + music = space.disk_usage(self._music_dir) + cassette = space.disk_usage(self._service.root) + music_free = music.free if music else 0 + if cassette is None: + return space.OK, need, music_free + value, unit = self._floor or (1.0, "%") + floor = st.disk_floor_bytes(value, unit, cassette.total) + return (space.verdict(need, music_free, cassette.free, floor), + need, music_free) + + def _refresh_bar(self): + verdict, need, free = self.space_state() + text = f"Cassetted {space.human(need)} · {space.human(free)} free" + color = {space.TIGHT: TIGHT_ORANGE, space.FULL: FULL_RED}.get(verdict) + self.size_label.setText(text) + self.size_label.setStyleSheet(f"color: {color}; font-weight: bold;" + if color else "") + self.size_label.setToolTip({ + space.TIGHT: "These would fit, but they'd leave so little room " + "that Syncthing stops syncing.", + space.FULL: "There isn't room for these songs.", + }.get(verdict, "")) + + # ---- views ---- + + def _show_library(self): + self._window.content_stack.setCurrentWidget(self._view) + + def _show_playlist(self, pid: str): + playlist = self.library.playlist(pid) + if playlist is None: + return + tracks = [self.library.tracks[t] for t in playlist.track_ids + if t in self.library.tracks + and not self._hidden(t)] + self._pane.show_playlist(playlist.name, tracks) + self._window.content_stack.setCurrentWidget(self._pane) + + def _hidden(self, tid: int) -> bool: + return self.hide_have.isChecked() and self.matches.get(tid) == matching.EXACT + + def _apply_hide(self, on: bool): + tracks = {tid: t for tid, t in self.library.tracks.items() + if not self._hidden(tid)} + self._source.set_tracks(tracks) + self._view.reload() + current = self._window.sidebar.friend_list.currentItem() + if current is not None and self._window.content_stack.currentWidget() is self._pane: + self._show_playlist(current.data(Qt.ItemDataRole.UserRole)) + + def _foreground(self, track): + if self.matches.get(track.track_id) == matching.FUZZY: + return FUZZY_GREY + return None + + # ---- cassetting ---- + + def state_of(self, ids) -> str: + if not ids: + return OFF + hits = sum(1 for t in ids if t in self.cassetted) + if hits == 0: + return OFF + return ON if hits == len(ids) else PARTIAL + + def toggle(self, ids): + """All on → all off; otherwise all on (so a partly-cassetted artist + becomes fully cassetted — the snapshot of what's there now).""" + ids = [t for t in ids if t in self.library.tracks] + if ids and all(t in self.cassetted for t in ids): + self.cassetted.difference_update(ids) + else: + self.cassetted.update(ids) + self._refresh_bar() + + def _toggle_in_pane(self, tid: int): + self.toggle([tid]) + if self._view is not None: + self._view.refresh_cassette() + + def _toggle_follow(self, pid: str): + on = pid not in self.followed + (self.followed.add if on else self.followed.discard)(pid) + self._window.sidebar.friend_list.set_followed(pid, on) + self._refresh_bar() + + def wanted_ids(self) -> set[int]: + """What to ask for: cassetted songs plus every song of a followed + playlist.""" + wanted = set(self.cassetted) + for pid in self.followed: + playlist = self.library.playlist(pid) + if playlist is not None: + wanted.update(playlist.track_ids) + return wanted + + # ---- saving ---- + + def is_dirty(self) -> bool: + return self.active and (self.cassetted, self.followed) != self._saved + + def save_and_close(self) -> bool: + verdict, need, free = self.space_state() + if verdict != space.OK: + if verdict == space.FULL: + text = (f"These songs need {space.human(need)}, but only " + f"{space.human(free)} is free where your music is.") + else: + text = ("These songs would leave so little free space that " + "Syncthing stops syncing until you make room.") + answer = QMessageBox.warning( + self._window, "Not Enough Space", text + "\n\nSave anyway?", + QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Cancel) + if answer != QMessageBox.StandardButton.Save: + return False + self._service.save_requests(self.token, self.cassetted, self.followed, + self.wanted_ids()) + self._leave() + return True + + def cancel(self): + self._leave() + + def confirm_leave(self) -> bool: + """Before leaving by any other door: True when it's fine to go (saved, + discarded, or nothing to save); False to stay.""" + if not self.active: + return True + if not self.is_dirty(): + self._leave() + return True + answer = ask_save_changes(self._window) + if answer == SAVE: + return self.save_and_close() + if answer == DISCARD: + self._leave() + return True + return False diff --git a/lintunes/gui/library_view.py b/lintunes/gui/library_view.py index 41fe410..6b3ca8c 100644 --- a/lintunes/gui/library_view.py +++ b/lintunes/gui/library_view.py @@ -3,8 +3,9 @@ from collections import Counter from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QSplitter, QLineEdit, + QStyledItemDelegate, QStyleOptionViewItem, QStyle, QApplication, ) -from PyQt6.QtCore import Qt, QTimer, pyqtSignal +from PyQt6.QtCore import Qt, QTimer, QRectF, QPersistentModelIndex, pyqtSignal from lintunes.gui.table_settings import TableSettingsMixin from lintunes.gui.track_table import TrackTableView @@ -58,13 +59,93 @@ def _canonical_values(values) -> list[str]: return result +class _CassetteListDelegate(QStyledItemDelegate): + """Paints a cassette button at the left of every value row (friend mode's + artist and album columns); the "All" row gets none.""" + + def __init__(self, view): + super().__init__(view) + self._view = view + + def paint(self, painter, option, index): + if index.row() == 0 or self._view.cassette_state is None: + super().paint(painter, option, index) + return + from lintunes.gui import cassette_art + side = option.rect.height() + opt = QStyleOptionViewItem(option) + self.initStyleOption(opt, index) + opt.rect = option.rect.adjusted(side + 2, 0, 0, 0) + style = opt.widget.style() if opt.widget else QApplication.style() + # Selection background across the whole row, text after the button. + full = QStyleOptionViewItem(option) + self.initStyleOption(full, index) + full.text = "" + style.drawControl(QStyle.ControlElement.CE_ItemViewItem, full, + painter, full.widget) + style.drawControl(QStyle.ControlElement.CE_ItemViewItem, opt, + painter, opt.widget) + state = self._view.cassette_state(index.data()) + rect = cassette_art.button_rect(QRectF(option.rect.left(), option.rect.top(), + side, side)) + cassette_art.paint_cassette_button( + painter, rect, state, self._view.is_cassette_hovered(index), + option.palette) + + class _BrowserList(QListWidget): """One column of the genre/artist/album browser.""" + # Friend mode: the value whose cassette button was clicked. + cassette_clicked = pyqtSignal(str) + def __init__(self, parent=None): super().__init__(parent) self.setMinimumWidth(120) self.setUniformItemSizes(True) + # Callable[[str], "off"|"on"|"partial"] while in friend mode. + self.cassette_state = None + self._hover: QPersistentModelIndex | None = None + + def enable_cassette(self, state_for): + self.cassette_state = state_for + if state_for is not None: + self.setItemDelegate(_CassetteListDelegate(self)) + self.setMouseTracking(True) + self.viewport().update() + + def _button_hit(self, pos) -> bool: + index = self.indexAt(pos) + return (self.cassette_state is not None and index.isValid() + and index.row() > 0 + and pos.x() - self.visualRect(index).left() + < self.visualRect(index).height() + 2) + + def is_cassette_hovered(self, index) -> bool: + return (self._hover is not None and self._hover.isValid() + and self._hover.row() == index.row()) + + def mouseMoveEvent(self, event): + pos = event.position().toPoint() + new = QPersistentModelIndex(self.indexAt(pos)) if self._button_hit(pos) else None + if new != self._hover: + self._hover = new + self.viewport().update() + super().mouseMoveEvent(event) + + def leaveEvent(self, event): + if self._hover is not None: + self._hover = None + self.viewport().update() + super().leaveEvent(event) + + def mousePressEvent(self, event): + pos = event.position().toPoint() + if event.button() == Qt.MouseButton.LeftButton and self._button_hit(pos): + self.cassette_clicked.emit(self.indexAt(pos).data()) + self.viewport().update() + return # the button, not a navigation + super().mousePressEvent(event) def set_values(self, label: str, values: list[str]): current = self.selected_value() @@ -98,6 +179,7 @@ class LibraryView(TableSettingsMixin, QWidget): def __init__(self, manager, parent=None): super().__init__(parent) self._manager = manager + self._cassette = None # (state_of, toggle) in friend mode layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -173,6 +255,51 @@ class LibraryView(TableSettingsMixin, QWidget): self.reload() + # ---- friend mode (cassette) ---- + + def enable_cassette(self, state_of, toggle): + """Friend mode: cassette buttons on artists, albums and tracks. + ``state_of(track ids)`` -> "off"|"on"|"partial"; ``toggle(track ids)`` + turns them all on, or all off when they already are. Passing None + turns it off.""" + self._cassette = (state_of, toggle) if state_of else None + if state_of is None: + self.table.cassette_hooks = None + for lst in (self._artist_list, self._album_list): + lst.enable_cassette(None) + return + self.table.cassette_hooks = (lambda tid: state_of([tid]), + lambda tid: self._toggle([tid])) + self._artist_list.enable_cassette( + lambda v: state_of(self.group_track_ids("artist", v))) + self._album_list.enable_cassette( + lambda v: state_of(self.group_track_ids("album", v))) + if not getattr(self, "_cassette_wired", False): + self._cassette_wired = True + self._artist_list.cassette_clicked.connect( + lambda v: self._toggle(self.group_track_ids("artist", v))) + self._album_list.cassette_clicked.connect( + lambda v: self._toggle(self.group_track_ids("album", v))) + + def _toggle(self, ids): + if self._cassette is not None and ids: + self._cassette[1](ids) + self.refresh_cassette() + + def refresh_cassette(self): + for widget in (self._artist_list, self._album_list, self.table): + widget.viewport().update() + + def group_track_ids(self, kind: str, value: str) -> list[int]: + """The tracks a browser row stands for, under the current filters — + what cassetting that artist or album asks for.""" + value = value.casefold() + if kind == "artist": + return [t.track_id for t in self._filtered_tracks(up_to="genre") + if (t.album_artist or t.artist).casefold() == value] + return [t.track_id for t in self._filtered_tracks(up_to="artist") + if t.album.casefold() == value] + def set_header_height(self, height: int): # Kept in sync with the sidebar Library button and the playlist view's # name strip so the tracklist tops line up across views. diff --git a/lintunes/gui/main_window.py b/lintunes/gui/main_window.py index 2b59404..c309dee 100644 --- a/lintunes/gui/main_window.py +++ b/lintunes/gui/main_window.py @@ -209,6 +209,8 @@ class MainWindow(QMainWindow): # Wiring self._transport.play_clicked.connect(self.play_pause) self._sidebar.library_selected.connect(self._show_library) + self._sidebar.friend_selector_clicked.connect( + self._cassette.show_friend_menu) self._sidebar.playlist_selected.connect(self._show_playlist) self._sidebar.tree.export_requested.connect(self._export_playlist) self._sidebar.art.download_art_requested.connect( @@ -392,6 +394,19 @@ class MainWindow(QMainWindow): def cassette(self) -> CassetteUi: return self._cassette + # What friend mode swaps in and out. + @property + def content_stack(self): + return self._content + + @property + def sidebar(self): + return self._sidebar + + def show_my_library(self): + self._sidebar.reflect_library() + self._show_library() + def _add_action(self, menu, text, shortcut, slot): action = QAction(text, self) if shortcut: @@ -1684,6 +1699,10 @@ class MainWindow(QMainWindow): self._inhibitor.release() def closeEvent(self, event): + # Unsaved cassette choices in friend mode: Save / Don't Save / Cancel. + if not self._cassette.confirm_quit(): + event.ignore() + return # Quitting mid-transfer abandons a partial file on the device (it # self-heals next sync, but silently) — make it a deliberate choice. # A link import has nothing half-written on a device to warn about: diff --git a/lintunes/gui/sidebar.py b/lintunes/gui/sidebar.py index b6a3a51..7187876 100644 --- a/lintunes/gui/sidebar.py +++ b/lintunes/gui/sidebar.py @@ -1,6 +1,7 @@ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu, QInputDialog, QMessageBox, QAbstractItemView, QLineEdit, QApplication, + QHBoxLayout, QListWidget, QListWidgetItem, QStackedWidget, ) from PyQt6.QtCore import Qt, QTimer, QPoint, pyqtSignal from PyQt6.QtGui import QBrush, QPainter, QPalette, QPixmap @@ -29,6 +30,13 @@ class SidebarPanel(QWidget): library_selected = pyqtSignal() playlist_selected = pyqtSignal(str) + # Friend sharing: the ▾ beside Library was clicked (show the friends + # popup), and in friend mode their library / one of their playlists was + # chosen, or a playlist's UFO (follow) toggled. + friend_selector_clicked = pyqtSignal() + friend_library_selected = pyqtSignal() + friend_playlist_selected = pyqtSignal(str) + follow_toggled = pyqtSignal(str) def __init__(self, manager, player, parent=None): super().__init__(parent) @@ -36,27 +44,77 @@ class SidebarPanel(QWidget): layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) + self.friend_mode = False + top = QWidget() + top_row = QHBoxLayout(top) + top_row.setContentsMargins(0, 0, 0, 0) + top_row.setSpacing(0) self.library_button = QPushButton("Library") self.library_button.setCheckable(True) self.library_button.setChecked(True) + # The library selector: a small square cut from the button's right + # end, flush against it. Shown once there is a friend to choose. + self.friend_button = QPushButton("▾") + self.friend_button.setToolTip("Browse a friend's library") + self.friend_button.setVisible(False) + top_row.addWidget(self.library_button, stretch=1) + top_row.addWidget(self.friend_button) self.refresh_style() - layout.addWidget(self.library_button) + layout.addWidget(top) + self._lists = QStackedWidget() self.tree = PlaylistTree(manager) - layout.addWidget(self.tree, stretch=1) + self._lists.addWidget(self.tree) + self.friend_list = FriendPlaylistList() + self._lists.addWidget(self.friend_list) + layout.addWidget(self._lists, stretch=1) self.art = SidebarArt(player) layout.addWidget(self.art) self.library_button.clicked.connect(self._on_library_clicked) self.tree.playlist_selected.connect(self._on_playlist_selected) + self.friend_button.clicked.connect(self.friend_selector_clicked) + self.friend_list.playlist_selected.connect(self._on_friend_playlist) + self.friend_list.follow_toggled.connect(self.follow_toggled) def _on_library_clicked(self): self.library_button.setChecked(True) + if self.friend_mode: + self.friend_list.clearSelection() + self.friend_list.setCurrentItem(None) + self.friend_library_selected.emit() + return self.tree.clearSelection() self.tree.setCurrentItem(None) self.library_selected.emit() + def _on_friend_playlist(self, pid: str): + self.library_button.setChecked(False) + self.friend_playlist_selected.emit(pid) + + # ---- friend mode ---- + + def set_friend_selector_visible(self, visible: bool): + self.friend_button.setVisible(visible) + self.refresh_style() + + def show_friend(self, library_name: str, playlists, followed: set): + """Friend mode: the Library button becomes their library (purple) and + the tree their shared playlists, each with a UFO follow toggle.""" + self.friend_mode = True + self.library_button.setText(library_name) + self.friend_list.set_playlists(playlists, followed) + self._lists.setCurrentWidget(self.friend_list) + self.library_button.setChecked(True) + self.refresh_style() + + def show_mine(self): + self.friend_mode = False + self.library_button.setText("Library") + self._lists.setCurrentWidget(self.tree) + self.refresh_style() + def _on_playlist_selected(self, pid: str): self.library_button.setChecked(False) self.playlist_selected.emit(pid) @@ -79,21 +137,102 @@ class SidebarPanel(QWidget): def apply_metrics(self, header_strip: int): self.library_button.setFixedHeight(header_strip) + self.friend_button.setFixedHeight(header_strip) + # Square: the button's own height, minus its vertical margins. + self.friend_button.setFixedWidth(max(16, header_strip - 4)) def refresh_style(self): """(Re)build the Library button's look from the live palette, so its - emboss follows theme changes like every other button's.""" + emboss follows theme changes like every other button's. With the + friend selector showing, the two share a border and have square + corners where they meet; in friend mode both turn light purple.""" + from lintunes.gui.cassette_art import FRIEND_PURPLE, FRIEND_PURPLE_DARK palette = QApplication.palette() base = palette.color(QPalette.ColorRole.AlternateBase) lit = palette.color(QPalette.ColorRole.Highlight) + split = not self.friend_button.isHidden() + border = "palette(mid)" + if self.friend_mode: + base = lit = FRIEND_PURPLE + border = FRIEND_PURPLE_DARK.name() + checked_text = "black" if self.friend_mode else "palette(highlighted-text)" + joint = ("border-top-right-radius: 0; border-bottom-right-radius: 0;" + " margin: 2px 0 2px 8px;" if split else + "margin: 2px 4px 2px 8px;") self.library_button.setStyleSheet( "QPushButton { text-align: left; padding-left: 10px;" - " border: 1px solid palette(mid); border-radius: 4px;" - " margin: 2px 4px 2px 8px;" + f" border: 1px solid {border}; border-radius: 4px; {joint}" f" background: {theme.emboss_gradient(base)}; }}" "QPushButton:checked {" f" background: {theme.emboss_gradient(lit, pressed=True)};" - " color: palette(highlighted-text); }") + f" color: {checked_text}; }}") + self.friend_button.setStyleSheet( + "QPushButton { padding: 0;" + f" border: 1px solid {border}; border-left: none;" + " border-radius: 4px; border-top-left-radius: 0;" + " border-bottom-left-radius: 0; margin: 2px 4px 2px 0;" + f" background: {theme.emboss_gradient(base)}; }}" + "QPushButton:pressed {" + f" background: {theme.emboss_gradient(base, pressed=True)}; }}") + + +class FriendPlaylistList(QListWidget): + """A friend's shared playlists in friend mode. The UFO at each row's left + is the follow toggle: purple when followed, a grey outline when not.""" + + playlist_selected = pyqtSignal(str) + follow_toggled = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.setUniformItemSizes(True) + self._followed: set[str] = set() + self.currentItemChanged.connect(self._on_current) + + def set_playlists(self, playlists, followed: set): + self._followed = set(followed) + self.blockSignals(True) + self.clear() + for playlist in playlists: + item = QListWidgetItem(playlist.name) + item.setData(Qt.ItemDataRole.UserRole, playlist.pid) + self.addItem(item) + self.blockSignals(False) + self._refresh_icons() + + def set_followed(self, pid: str, on: bool): + (self._followed.add if on else self._followed.discard)(pid) + self._refresh_icons() + + def _refresh_icons(self): + from lintunes.gui.cassette_art import ufo_icon + on, off = ufo_icon(followed=True), ufo_icon(followed=False) + for row in range(self.count()): + item = self.item(row) + followed = item.data(Qt.ItemDataRole.UserRole) in self._followed + item.setIcon(on if followed else off) + item.setToolTip("Following — plays from your sidebar. Click the " + "UFO to stop." if followed else + "Click the UFO to follow this playlist.") + + def _icon_hit(self, pos) -> bool: + index = self.indexAt(pos) + if not index.isValid(): + return False + rect = self.visualRect(index) + return pos.x() - rect.left() < self.iconSize().width() + 8 + + def mousePressEvent(self, event): + pos = event.position().toPoint() + if event.button() == Qt.MouseButton.LeftButton and self._icon_hit(pos): + pid = self.indexAt(pos).data(Qt.ItemDataRole.UserRole) + self.follow_toggled.emit(pid) + return + super().mousePressEvent(event) + + def _on_current(self, current, _previous): + if current is not None: + self.playlist_selected.emit(current.data(Qt.ItemDataRole.UserRole)) class SidebarArt(QWidget): diff --git a/lintunes/gui/track_table.py b/lintunes/gui/track_table.py index f4f7866..61e60ab 100644 --- a/lintunes/gui/track_table.py +++ b/lintunes/gui/track_table.py @@ -85,6 +85,13 @@ ALL_COLUMNS = [ COLUMN_MAP = {col[0]: col for col in ALL_COLUMNS} +# Friend sharing's "ask for this song" column. Registered so a view can show +# it, but not in ALL_COLUMNS: only views that turn on ``cassette_hooks`` offer +# it in the header menu, and it always sits first. +CASSETTE_FIELD = "cassette" +CASSETTE_COLUMN = (CASSETTE_FIELD, "", 30) +COLUMN_MAP[CASSETTE_FIELD] = CASSETTE_COLUMN + def format_time(ms: int) -> str: if ms <= 0: @@ -156,7 +163,11 @@ class RatingDelegate(QStyledItemDelegate): def paint(self, painter, option, index): model = self._view.model_ - if model.fields[index.column()] != "rating": + field = model.fields[index.column()] + if field == CASSETTE_FIELD: + self._paint_cassette(painter, option, index) + return + if field != "rating": super().paint(painter, option, index) return opt = QStyleOptionViewItem(option) @@ -194,6 +205,23 @@ class RatingDelegate(QStyledItemDelegate): painter.restore() + def _paint_cassette(self, painter, option, index): + from lintunes.gui import cassette_art + opt = QStyleOptionViewItem(option) + self.initStyleOption(opt, index) + opt.text = "" + style = opt.widget.style() if opt.widget else QApplication.style() + style.drawControl(QStyle.ControlElement.CE_ItemViewItem, opt, + painter, opt.widget) + state = self._view.cassette_state_at(index) + if state is None: + return + rect = cassette_art.button_rect(QRectF(opt.rect)) + cassette_art.paint_cassette_button( + painter, rect, state, self._view.is_cassette_hovered(index), + opt.palette) + + def _format_size(size: int) -> str: if size <= 0: return "" @@ -339,6 +367,9 @@ class TrackTableModel(QAbstractTableModel): self._with_index = with_index_column self._now_playing_id: int | None = None self._now_playing_active = False + # Optional Callable[[Track], QColor | None]: a per-row text color + # (friend mode greys songs I probably already have). + self.foreground_for = None self.set_visible_columns(["name", "artist", "album"]) def set_now_playing(self, track_id: int | None, playing: bool): @@ -472,6 +503,10 @@ class TrackTableModel(QAbstractTableModel): if role == Qt.ItemDataRole.DisplayRole: return index.row() + 1 return None + if field == CASSETTE_FIELD: + return None # painted by the delegate + if role == Qt.ItemDataRole.ForegroundRole and self.foreground_for: + return self.foreground_for(track) if (role == Qt.ItemDataRole.DecorationRole and field == "name" and track.track_id == self._now_playing_id): @@ -569,6 +604,14 @@ class TrackTableView(QTableView): # itself reaching into the library. self.playlists_for_track = None # Callable[[int], list[tuple[str,str]]] + # Friend sharing. read_only: someone else's songs — no playing, no + # editing, no dragging into my playlists (their ids aren't mine). + # cassette_hooks: (state_for(track id) -> "off"|"on"|None, + # toggled(track id)) — turns the cassette column on. + self.read_only = False + self.cassette_hooks = None + self._hover_cassette_index: QPersistentModelIndex | None = None + # Drag and drop (default indicator off: we paint a glowing line) self.setDragEnabled(True) self.setAcceptDrops(True) @@ -600,11 +643,43 @@ class TrackTableView(QTableView): def set_source_playlist(self, pid: str): self._source_playlist_id = pid + def set_read_only(self, read_only: bool): + self.read_only = read_only + self.setDragEnabled(not read_only) + self.setAcceptDrops(not read_only and self._content_editable) + + def cassette_state_at(self, index): + if self.cassette_hooks is None or not index.isValid(): + return None + track = self.model_.track_at(self.model_.source_row(index.row())) + return self.cassette_hooks[0](track.track_id) + + def is_cassette_hovered(self, index) -> bool: + hover = self._hover_cassette_index + return (hover is not None and hover.isValid() + and hover.row() == index.row() + and hover.column() == index.column()) + + def _is_cassette_index(self, index) -> bool: + return (self.cassette_hooks is not None and index.isValid() + and self.model_.fields[index.column()] == CASSETTE_FIELD) + + def _set_cassette_hover(self, index): + new = QPersistentModelIndex(index) if index is not None else None + old = self._hover_cassette_index + if new == old: + return + self._hover_cassette_index = new + for persistent in (old, new): + if persistent is not None and persistent.isValid(): + self.viewport().update(self.visualRect( + self.model_.index(persistent.row(), persistent.column()))) + def set_content_editable(self, editable: bool): """Smart playlists pass False: dropping, reordering, cut/paste and remove are all disabled (membership is derived from criteria).""" self._content_editable = editable - self.setAcceptDrops(editable) + self.setAcceptDrops(editable and not self.read_only) def context_id(self) -> str: """Identifies what this table is currently showing, so the now-playing @@ -691,7 +766,7 @@ class TrackTableView(QTableView): def copy_selection(self): ids = self.selected_track_ids() - if not ids: + if not ids or self.read_only: return QApplication.clipboard().setMimeData(make_tracks_mime( ids, self._source_playlist_id, self.selected_source_rows())) @@ -700,6 +775,8 @@ class TrackTableView(QTableView): """Copy the selection, then — in a playlist — remove it immediately so it visibly leaves; a paste re-inserts it at the target. From the library there's nothing to remove, so this is just a copy.""" + if self.read_only: + return ids = self.selected_track_ids() if not ids: return @@ -742,7 +819,10 @@ class TrackTableView(QTableView): def _show_header_menu(self, pos): menu = QMenu(self) visible = [f for f in self.model_.fields if f != INDEX_FIELD] - for field, label, _w in ALL_COLUMNS: + columns = list(ALL_COLUMNS) + if self.cassette_hooks is not None: + columns = [(CASSETTE_FIELD, "Cassette", 30)] + columns + for field, label, _w in columns: action = menu.addAction(label) action.setCheckable(True) action.setChecked(field in visible) @@ -754,7 +834,7 @@ class TrackTableView(QTableView): if chosen.isChecked(): new_visible = visible + [field] # Preserve canonical column ordering - new_visible = [f for f, _l, _w in ALL_COLUMNS if f in new_visible] + new_visible = [f for f, _l, _w in columns if f in new_visible] else: new_visible = [f for f in visible if f != field] if not new_visible: @@ -766,6 +846,9 @@ class TrackTableView(QTableView): def _show_context_menu(self, pos): if not self.selected_track_ids(): return + if self.read_only: + self._show_read_only_menu(pos) + return menu = QMenu(self) info_action = menu.addAction("Get Info\tCtrl+I") copy_action = menu.addAction("Copy\tCtrl+C") @@ -850,6 +933,17 @@ class TrackTableView(QTableView): elif chosen is delete_action: self.delete_from_library_requested.emit(self.selected_track_ids()) + def _show_read_only_menu(self, pos): + """Someone else's songs: nothing here edits, plays or copies them.""" + selected = self.selected_track_ids() + if len(selected) != 1: + return + menu = QMenu(self) + youtube_action = menu.addAction("lookup on youtube") + if menu.exec(self.viewport().mapToGlobal(pos)) is youtube_action: + track = self.model_.track_at(self.selected_source_rows()[0]) + QDesktopServices.openUrl(youtube_search_url(track)) + # ---- rating hover / click ---- def _is_rating_index(self, index) -> bool: @@ -877,16 +971,27 @@ class TrackTableView(QTableView): def mouseMoveEvent(self, event): index = self.indexAt(event.position().toPoint()) self._set_rating_hover(index if self._is_rating_index(index) else None) + self._set_cassette_hover(index if self._is_cassette_index(index) else None) super().mouseMoveEvent(event) def leaveEvent(self, event): self._set_rating_hover(None) + self._set_cassette_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_cassette_index(index): + track = self.model_.track_at(self.model_.source_row(index.row())) + if self.cassette_state_at(index) is not None: + self.cassette_hooks[1](track.track_id) + self.viewport().update(self.visualRect(index)) + return # a cassette click never selects or starts a drag + if self.read_only and self._is_rating_index(index): + super().mousePressEvent(event) + return if self._is_rating_index(index): track = self.model_.track_at(self.model_.source_row(index.row())) slot = rating_slot_at( @@ -900,6 +1005,8 @@ class TrackTableView(QTableView): # ---- playback ---- def _on_double_click(self, index): + if self.read_only: + return # browsing only: the audio isn't here ids = self.view_order_track_ids() if ids: self.play_requested.emit(ids, index.row()) diff --git a/tests/test_round67.py b/tests/test_round67.py new file mode 100644 index 0000000..31e6815 --- /dev/null +++ b/tests/test_round67.py @@ -0,0 +1,298 @@ +"""Round 67: friend mode — browsing a friend's library and cassetting songs. + +* Matching is best effort: exact = artist + album + title + length within 2 s; + fuzzy = artist + title. Case, accents, punctuation, "feat." and a leading + "The" don't count. +* The friend's library is read-only here: no playing, no editing, no + dragging their ids into my playlists. +* Nothing is saved as you click. Save and Close writes requests.json; + Cancel drops everything; leaving any other way with changes asks. +* The size readout goes orange when Syncthing would stall (its free-space + floor) and red when the songs won't fit. +""" +import json + +import pytest +from PyQt6.QtCore import Qt + +from lintunes.cassette import matching, publish, requests as req, share, space +from lintunes.cassette.friend_library import load_friend_library +from lintunes.cassette.service import CassetteService +from lintunes.cassette.state import Friend, Selection, in_dir, out_dir +from lintunes.models import Playlist, Track +from lintunes.models.library import Library + + +def _t(tid, name, artist, album, ms=200_000, **kw): + return Track(track_id=tid, name=name, artist=artist, album=album, + total_time=ms, size=5_000_000, **kw) + + +# ---- matching ---- + +class TestMatching: + def test_normalize(self): + assert matching.normalize("The Beatles") == "beatles" + assert matching.normalize("Beyoncé") == "beyonce" + assert matching.normalize("Crazy In Love (feat. Jay-Z)") == "crazy in love" + assert matching.normalize("Hey Jude - Remastered 2015") == "hey jude remastered 2015" + assert matching.normalize("Hey Jude (Remastered 2015)") == "hey jude" + assert matching.normalize("Simon & Garfunkel") == "simon and garfunkel" + assert matching.normalize("Song ft. Someone") == "song" + + def test_exact_needs_album_and_length(self): + index = matching.LibraryIndex([_t(1, "Hey Jude", "The Beatles", "1")]) + assert index.classify(_t(9, "hey jude", "Beatles", "1", ms=201_500)) == matching.EXACT + assert index.classify(_t(9, "Hey Jude", "The Beatles", "1", ms=210_000)) == matching.FUZZY + assert index.classify(_t(9, "Hey Jude", "The Beatles", "Past Masters")) == matching.FUZZY + assert index.classify(_t(9, "Let It Be", "The Beatles", "1")) is None + + def test_album_artist_or_artist(self): + mine = _t(1, "Track", "Guest", "Comp", album_artist="Various Artists") + index = matching.LibraryIndex([mine]) + assert index.classify(_t(9, "Track", "Guest", "Comp")) == matching.EXACT + + def test_unknown_length_still_exact(self): + index = matching.LibraryIndex([_t(1, "A", "B", "C", ms=0)]) + assert index.classify(_t(9, "A", "B", "C")) == matching.EXACT + + +# ---- space ---- + +class TestSpace: + def test_verdicts(self): + gb = 10**9 + assert space.verdict(1 * gb, 50 * gb, 50 * gb, 1 * gb) == space.OK + assert space.verdict(49 * gb, 50 * gb, 50 * gb, 2 * gb) == space.TIGHT + assert space.verdict(60 * gb, 50 * gb, 500 * gb, 1 * gb) == space.FULL + + def test_human(self): + assert space.human(740_000) == "740 KB" + assert space.human(1_200_000_000) == "1.2 GB" + + def test_disk_usage_of_missing_path_uses_its_parent(self, tmp_path): + assert space.disk_usage(tmp_path / "not" / "yet") is not None + + +# ---- requests & the friend's copy ---- + +class TestFiles: + def test_requests_round_trip(self, tmp_path): + assert req.write_requests(tmp_path, [3, 1, 3]) + assert json.loads((tmp_path / req.REQUESTS_FILE).read_text())["tracks"] == [1, 3] + assert not req.write_requests(tmp_path, {1, 3}) + assert req.read_requests(tmp_path) == {1, 3} + + def test_garbled_requests_is_empty(self, tmp_path): + (tmp_path / req.REQUESTS_FILE).write_text("{half a fi") + assert req.read_requests(tmp_path) == set() + + def test_load_friend_library(self, tmp_path): + library = Library() + library.tracks[5] = _t(5, "Song", "Artist", "Album", rating=100) + library.playlists["P"] = Playlist(name="Mix", persistent_id="P", + track_ids=[5]) + share.publish_share(tmp_path, library, Selection(all_library=True, + all_playlists=True)) + publish.publish_intro(tmp_path, "Sam", None) + friend = load_friend_library(tmp_path) + assert friend.name == "Sam" + assert friend.tracks[5].name == "Song" + assert friend.tracks[5].rating == 0 # never crossed over + assert friend.playlists[0].name == "Mix" + assert friend.playlists[0].track_ids == [5] + + def test_missing_or_garbled_is_empty(self, tmp_path): + assert load_friend_library(tmp_path, "Sam").tracks == {} + (tmp_path / share.LIBRARY_FILE).write_text("[1, 2") + (tmp_path / share.PLAYLISTS_FILE).write_text(json.dumps( + {"playlists": [{"id": "P", "track_ids": ["x", 99]}]})) + friend = load_friend_library(tmp_path, "Sam") + assert friend.name == "Sam" and friend.tracks == {} + assert friend.playlists[0].track_ids == [] + + +# ---- read-only table and the cassette column ---- + +class TestTable: + def test_read_only_never_plays_or_copies(self, qapp): + from lintunes.gui.track_table import TrackTableView + table = TrackTableView() + table.set_read_only(True) + table.set_tracks([_t(1, "A", "B", "C")]) + played = [] + table.play_requested.connect(lambda *a: played.append(a)) + table._on_double_click(table.model_.index(0, 0)) + assert played == [] + assert not table.dragEnabled() and not table.acceptDrops() + + def test_cassette_column_click_toggles(self, qapp): + from PyQt6.QtCore import QPointF + from PyQt6.QtGui import QMouseEvent + from lintunes.gui.track_table import CASSETTE_FIELD, TrackTableView + table = TrackTableView() + on = set() + table.cassette_hooks = (lambda tid: "on" if tid in on else "off", + lambda tid: on.symmetric_difference_update({tid})) + table.model_.set_visible_columns([CASSETTE_FIELD, "name"]) + table.set_tracks([_t(7, "A", "B", "C")]) + table.resize(400, 200) + table.show() + rect = table.visualRect(table.model_.index(0, 0)) + event = QMouseEvent(QMouseEvent.Type.MouseButtonPress, + QPointF(rect.center()), QPointF(rect.center()), + Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier) + table.mousePressEvent(event) + assert on == {7} + assert table.cassette_state_at(table.model_.index(0, 0)) == "on" + + +# ---- friend mode itself ---- + +class _Window: + """What FriendMode touches on MainWindow, and nothing more.""" + + def __init__(self): + from PyQt6.QtWidgets import QMainWindow, QStackedWidget + from lintunes.gui.sidebar import SidebarPanel + from unittest.mock import MagicMock + self.main = QMainWindow() + self.content_stack = QStackedWidget() + self.main.setCentralWidget(self.content_stack) + manager = MagicMock() + manager.library.playlists = {} + player = MagicMock() + self.sidebar = SidebarPanel(manager, player) + self.shown_mine = 0 + + def statusBar(self): + return self.main.statusBar() + + def show_my_library(self): + self.shown_mine += 1 + + +@pytest.fixture +def visit(qapp, tmp_path): + from lintunes.gui.friend_mode import FriendMode + friend_lib = Library() + for tid, (name, artist, album) in enumerate( + [("Hey Jude", "The Beatles", "1"), ("Let It Be", "The Beatles", "1"), + ("Blackbird", "The Beatles", "White"), ("Doo Wop", "Lauryn Hill", "Mis")], + start=10): + friend_lib.tracks[tid] = _t(tid, name, artist, album) + friend_lib.playlists["P"] = Playlist(name="Road Trip", persistent_id="P", + track_ids=[13, 10]) + service = CassetteService(root=tmp_path, config_loader=lambda: {}) + service._state.friends["tok"] = Friend(token="tok", device_id="X", name="Sam", + in_folder="f-in") + share.publish_share(in_dir(tmp_path, "tok"), friend_lib, + Selection(all_library=True, all_playlists=True)) + mine = Library() + mine.tracks[1] = _t(1, "Hey Jude", "Beatles", "1") # exact + mine.tracks[2] = _t(2, "Blackbird", "The Beatles", "Other") # fuzzy + window = _Window() + mode = FriendMode(window.main, service, mine, tmp_path) + mode._window = window + mode.enter("tok") + return mode, window, service + + +class TestFriendMode: + def test_enter_shows_their_library_purple(self, visit): + mode, window, _ = visit + assert window.sidebar.friend_mode + assert window.sidebar.library_button.text() == "Sam's Library" + assert window.content_stack.currentWidget() is mode._view + assert mode._view.table.read_only + assert mode.matches[10] == matching.EXACT + assert mode.matches[12] == matching.FUZZY + assert mode.matches[13] is None + + def test_artist_cassettes_down_the_tree(self, visit): + mode, _, _ = visit + ids = mode._view.group_track_ids("artist", "The Beatles") + mode.toggle(ids) + assert mode.cassetted == {10, 11, 12} + assert mode.state_of([10, 13]) == "partial" + mode.toggle(ids) + assert mode.cassetted == set() + + def test_nothing_saved_until_save(self, visit, tmp_path): + mode, window, service = visit + mode.toggle([13]) + mode._toggle_follow("P") + assert not (out_dir(tmp_path, "tok") / req.REQUESTS_FILE).exists() + assert service.state().friends["tok"].cassetted == [] + assert mode.is_dirty() + mode.save_and_close() + friend = service.state().friends["tok"] + assert friend.cassetted == [13] and friend.followed == ["P"] + # the followed playlist's songs are asked for too + assert req.read_requests(out_dir(tmp_path, "tok")) == {10, 13} + assert not mode.active and not window.sidebar.friend_mode + assert window.shown_mine == 1 + + def test_cancel_discards(self, visit, tmp_path): + mode, _, service = visit + mode.toggle([13]) + mode.cancel() + assert service.state().friends["tok"].cassetted == [] + assert not (out_dir(tmp_path, "tok") / req.REQUESTS_FILE).exists() + + def test_leaving_with_changes_asks(self, visit, monkeypatch): + from lintunes.gui import friend_mode + mode, _, service = visit + mode.toggle([11]) + monkeypatch.setattr(friend_mode, "ask_save_changes", lambda parent: friend_mode.STAY) + assert not mode.confirm_leave() and mode.active + monkeypatch.setattr(friend_mode, "ask_save_changes", lambda parent: friend_mode.DISCARD) + assert mode.confirm_leave() and not mode.active + assert service.state().friends["tok"].cassetted == [] + + def test_leaving_clean_never_asks(self, visit, monkeypatch): + from lintunes.gui import friend_mode + mode, _, _ = visit + monkeypatch.setattr(friend_mode, "ask_save_changes", + lambda parent: pytest.fail("asked")) + assert mode.confirm_leave() + + def test_hide_tracks_i_have(self, visit): + mode, _, _ = visit + mode.hide_have.setChecked(True) + shown = {t.track_id for t in mode._view.table.model_.tracks} + assert 10 not in shown # exact: hidden + assert {11, 12, 13} <= shown # fuzzy stays (grey) + grey = mode._foreground(mode.library.tracks[12]) + assert grey is not None + + def test_their_playlist_in_order(self, visit): + mode, window, _ = visit + mode._show_playlist("P") + assert window.content_stack.currentWidget() is mode._pane + assert [t.track_id for t in mode._pane.table.model_.tracks] == [13, 10] + assert mode._pane.table.read_only + + def test_space_turns_red(self, visit, monkeypatch): + mode, _, _ = visit + from collections import namedtuple + usage = namedtuple("usage", "total used free") + monkeypatch.setattr(space, "disk_usage", + lambda path: usage(100_000_000, 99_000_000, 1_000_000)) + mode.toggle([10, 11]) + assert mode.space_state()[0] == space.FULL + assert "#d0342c" in mode.size_label.styleSheet() + + +class TestSelector: + def test_hidden_until_there_is_a_friend(self, qapp): + from unittest.mock import MagicMock + from lintunes.gui.sidebar import SidebarPanel + manager = MagicMock() + manager.library.playlists = {} + sidebar = SidebarPanel(manager, MagicMock()) + assert sidebar.friend_button.isHidden() + sidebar.set_friend_selector_visible(True) + assert not sidebar.friend_button.isHidden() + assert "border-top-right-radius: 0" in sidebar.library_button.styleSheet()