v0.29.0: Cassette friend mode — browse a friend's library, cassette songs

- A ▾ cut from the right end of the Library button (shown once there's a
  friend) opens a popup of friends. Choosing one enters friend mode: the
  button becomes "<Name>'s Library" in light purple, the sidebar lists
  their shared playlists with a UFO follow toggle on each, and the content
  area is their library — browse only, nothing plays or edits.
- A cassette column on the far left and cassette buttons on every artist
  and album: pressed grey = send me this, medium grey on hover to let go.
  An artist cassettes all its albums and tracks (a snapshot).
- Bottom bar: Hide tracks I have (exact matches: artist, album, title,
  length within 2 s); close matches show light grey. Size of what's
  cassetted against free space: orange when Syncthing would stall at its
  free-space floor, red when it won't fit.
- Save and Close writes requests.json; Cancel discards; the ▾ or quitting
  with changes asks Save / Don't Save / Cancel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 21:27:06 -07:00
co-authored by Claude Opus 5.5
parent 759fa85153
commit 8099cfc85f
15 changed files with 1565 additions and 14 deletions
+12
View File
@@ -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 `syncthing` run a real handshake with `pytest --syncthing`. The service
starts from `run_gui`, never from constructing `MainWindow` — tests build starts from `run_gui`, never from constructing `MainWindow` — tests build
windows by the dozen and must not reach a real Syncthing. 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 - **`lintunes/cast/`** — Chromecast playback (Connections menu), using the
**media-receiver model**: `server.py` runs a `ThreadingHTTPServer` on an **media-receiver model**: `server.py` runs a `ThreadingHTTPServer` on an
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux.""" """LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.28.0" __version__ = "0.29.0"
+97
View File
@@ -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
+95
View File
@@ -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
+28
View File
@@ -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)}
+17
View File
@@ -237,6 +237,23 @@ class CassetteService(QObject):
changed.append(token) changed.append(token)
return changed 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 ---- # ---- user actions ----
def create_invite(self) -> str: def create_invite(self) -> str:
+49
View File
@@ -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"
+144
View File
@@ -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)
+62 -1
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
from PyQt6.QtCore import QObject, QTimer from PyQt6.QtCore import QObject, QTimer
from PyQt6.QtGui import QAction 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 host
from lintunes.cassette import syncthing_api as st from lintunes.cassette import syncthing_api as st
@@ -36,6 +36,7 @@ class CassetteUi(QObject):
self._actions: list[QAction] = [] self._actions: list[QAction] = []
self._service_factory = service_factory self._service_factory = service_factory
self.service = None self.service = None
self.friend_mode = None # FriendMode while browsing a friend
# ---- the service (host machine only) ---- # ---- the service (host machine only) ----
@@ -51,6 +52,7 @@ class CassetteUi(QObject):
else: else:
from lintunes.cassette.service import CassetteService from lintunes.cassette.service import CassetteService
self.service = CassetteService(parent=self) self.service = CassetteService(parent=self)
self.service.changed.connect(self._refresh_selector)
self.service.notice.connect(self._on_notice) self.service.notice.connect(self._on_notice)
self.service.step_failed.connect(self._on_step_failed) self.service.step_failed.connect(self._on_step_failed)
self.service.friends_added.connect(self._republish_timer.start) 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", self._on_step_failed("Updating what your friends can see",
str(exc)) 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): def shutdown(self):
if self.service is not None: if self.service is not None:
self.service.stop() self.service.stop()
+358
View File
@@ -0,0 +1,358 @@
"""Friend mode: browsing a friend's shared library and cassetting songs.
Entered from the ▾ beside Library. The Library button becomes "<Name>'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
+128 -1
View File
@@ -3,8 +3,9 @@ from collections import Counter
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QSplitter, QLineEdit, 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.table_settings import TableSettingsMixin
from lintunes.gui.track_table import TrackTableView from lintunes.gui.track_table import TrackTableView
@@ -58,13 +59,93 @@ def _canonical_values(values) -> list[str]:
return result 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): class _BrowserList(QListWidget):
"""One column of the genre/artist/album browser.""" """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): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.setMinimumWidth(120) self.setMinimumWidth(120)
self.setUniformItemSizes(True) 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]): def set_values(self, label: str, values: list[str]):
current = self.selected_value() current = self.selected_value()
@@ -98,6 +179,7 @@ class LibraryView(TableSettingsMixin, QWidget):
def __init__(self, manager, parent=None): def __init__(self, manager, parent=None):
super().__init__(parent) super().__init__(parent)
self._manager = manager self._manager = manager
self._cassette = None # (state_of, toggle) in friend mode
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
@@ -173,6 +255,51 @@ class LibraryView(TableSettingsMixin, QWidget):
self.reload() 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): def set_header_height(self, height: int):
# Kept in sync with the sidebar Library button and the playlist view's # Kept in sync with the sidebar Library button and the playlist view's
# name strip so the tracklist tops line up across views. # name strip so the tracklist tops line up across views.
+19
View File
@@ -209,6 +209,8 @@ class MainWindow(QMainWindow):
# Wiring # Wiring
self._transport.play_clicked.connect(self.play_pause) self._transport.play_clicked.connect(self.play_pause)
self._sidebar.library_selected.connect(self._show_library) 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.playlist_selected.connect(self._show_playlist)
self._sidebar.tree.export_requested.connect(self._export_playlist) self._sidebar.tree.export_requested.connect(self._export_playlist)
self._sidebar.art.download_art_requested.connect( self._sidebar.art.download_art_requested.connect(
@@ -392,6 +394,19 @@ class MainWindow(QMainWindow):
def cassette(self) -> CassetteUi: def cassette(self) -> CassetteUi:
return self._cassette 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): def _add_action(self, menu, text, shortcut, slot):
action = QAction(text, self) action = QAction(text, self)
if shortcut: if shortcut:
@@ -1684,6 +1699,10 @@ class MainWindow(QMainWindow):
self._inhibitor.release() self._inhibitor.release()
def closeEvent(self, event): 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 # Quitting mid-transfer abandons a partial file on the device (it
# self-heals next sync, but silently) — make it a deliberate choice. # self-heals next sync, but silently) — make it a deliberate choice.
# A link import has nothing half-written on a device to warn about: # A link import has nothing half-written on a device to warn about:
+145 -6
View File
@@ -1,6 +1,7 @@
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu, QWidget, QVBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu,
QInputDialog, QMessageBox, QAbstractItemView, QLineEdit, QApplication, QInputDialog, QMessageBox, QAbstractItemView, QLineEdit, QApplication,
QHBoxLayout, QListWidget, QListWidgetItem, QStackedWidget,
) )
from PyQt6.QtCore import Qt, QTimer, QPoint, pyqtSignal from PyQt6.QtCore import Qt, QTimer, QPoint, pyqtSignal
from PyQt6.QtGui import QBrush, QPainter, QPalette, QPixmap from PyQt6.QtGui import QBrush, QPainter, QPalette, QPixmap
@@ -29,6 +30,13 @@ class SidebarPanel(QWidget):
library_selected = pyqtSignal() library_selected = pyqtSignal()
playlist_selected = pyqtSignal(str) 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): def __init__(self, manager, player, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -36,27 +44,77 @@ class SidebarPanel(QWidget):
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(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 = QPushButton("Library")
self.library_button.setCheckable(True) self.library_button.setCheckable(True)
self.library_button.setChecked(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() self.refresh_style()
layout.addWidget(self.library_button) layout.addWidget(top)
self._lists = QStackedWidget()
self.tree = PlaylistTree(manager) 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) self.art = SidebarArt(player)
layout.addWidget(self.art) layout.addWidget(self.art)
self.library_button.clicked.connect(self._on_library_clicked) self.library_button.clicked.connect(self._on_library_clicked)
self.tree.playlist_selected.connect(self._on_playlist_selected) 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): def _on_library_clicked(self):
self.library_button.setChecked(True) 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.clearSelection()
self.tree.setCurrentItem(None) self.tree.setCurrentItem(None)
self.library_selected.emit() 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): def _on_playlist_selected(self, pid: str):
self.library_button.setChecked(False) self.library_button.setChecked(False)
self.playlist_selected.emit(pid) self.playlist_selected.emit(pid)
@@ -79,21 +137,102 @@ class SidebarPanel(QWidget):
def apply_metrics(self, header_strip: int): def apply_metrics(self, header_strip: int):
self.library_button.setFixedHeight(header_strip) 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): def refresh_style(self):
"""(Re)build the Library button's look from the live palette, so its """(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() palette = QApplication.palette()
base = palette.color(QPalette.ColorRole.AlternateBase) base = palette.color(QPalette.ColorRole.AlternateBase)
lit = palette.color(QPalette.ColorRole.Highlight) 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( self.library_button.setStyleSheet(
"QPushButton { text-align: left; padding-left: 10px;" "QPushButton { text-align: left; padding-left: 10px;"
" border: 1px solid palette(mid); border-radius: 4px;" f" border: 1px solid {border}; border-radius: 4px; {joint}"
" margin: 2px 4px 2px 8px;"
f" background: {theme.emboss_gradient(base)}; }}" f" background: {theme.emboss_gradient(base)}; }}"
"QPushButton:checked {" "QPushButton:checked {"
f" background: {theme.emboss_gradient(lit, pressed=True)};" 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): class SidebarArt(QWidget):
+112 -5
View File
@@ -85,6 +85,13 @@ ALL_COLUMNS = [
COLUMN_MAP = {col[0]: col for col in 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: def format_time(ms: int) -> str:
if ms <= 0: if ms <= 0:
@@ -156,7 +163,11 @@ class RatingDelegate(QStyledItemDelegate):
def paint(self, painter, option, index): def paint(self, painter, option, index):
model = self._view.model_ 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) super().paint(painter, option, index)
return return
opt = QStyleOptionViewItem(option) opt = QStyleOptionViewItem(option)
@@ -194,6 +205,23 @@ class RatingDelegate(QStyledItemDelegate):
painter.restore() 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: def _format_size(size: int) -> str:
if size <= 0: if size <= 0:
return "" return ""
@@ -339,6 +367,9 @@ class TrackTableModel(QAbstractTableModel):
self._with_index = with_index_column self._with_index = with_index_column
self._now_playing_id: int | None = None self._now_playing_id: int | None = None
self._now_playing_active = False 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"]) self.set_visible_columns(["name", "artist", "album"])
def set_now_playing(self, track_id: int | None, playing: bool): def set_now_playing(self, track_id: int | None, playing: bool):
@@ -472,6 +503,10 @@ class TrackTableModel(QAbstractTableModel):
if role == Qt.ItemDataRole.DisplayRole: if role == Qt.ItemDataRole.DisplayRole:
return index.row() + 1 return index.row() + 1
return None 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" if (role == Qt.ItemDataRole.DecorationRole and field == "name"
and track.track_id == self._now_playing_id): and track.track_id == self._now_playing_id):
@@ -569,6 +604,14 @@ class TrackTableView(QTableView):
# itself reaching into the library. # itself reaching into the library.
self.playlists_for_track = None # Callable[[int], list[tuple[str,str]]] 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) # Drag and drop (default indicator off: we paint a glowing line)
self.setDragEnabled(True) self.setDragEnabled(True)
self.setAcceptDrops(True) self.setAcceptDrops(True)
@@ -600,11 +643,43 @@ class TrackTableView(QTableView):
def set_source_playlist(self, pid: str): def set_source_playlist(self, pid: str):
self._source_playlist_id = pid 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): def set_content_editable(self, editable: bool):
"""Smart playlists pass False: dropping, reordering, cut/paste and """Smart playlists pass False: dropping, reordering, cut/paste and
remove are all disabled (membership is derived from criteria).""" remove are all disabled (membership is derived from criteria)."""
self._content_editable = editable self._content_editable = editable
self.setAcceptDrops(editable) self.setAcceptDrops(editable and not self.read_only)
def context_id(self) -> str: def context_id(self) -> str:
"""Identifies what this table is currently showing, so the now-playing """Identifies what this table is currently showing, so the now-playing
@@ -691,7 +766,7 @@ class TrackTableView(QTableView):
def copy_selection(self): def copy_selection(self):
ids = self.selected_track_ids() ids = self.selected_track_ids()
if not ids: if not ids or self.read_only:
return return
QApplication.clipboard().setMimeData(make_tracks_mime( QApplication.clipboard().setMimeData(make_tracks_mime(
ids, self._source_playlist_id, self.selected_source_rows())) 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 """Copy the selection, then — in a playlist — remove it immediately so
it visibly leaves; a paste re-inserts it at the target. From the 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.""" library there's nothing to remove, so this is just a copy."""
if self.read_only:
return
ids = self.selected_track_ids() ids = self.selected_track_ids()
if not ids: if not ids:
return return
@@ -742,7 +819,10 @@ class TrackTableView(QTableView):
def _show_header_menu(self, pos): def _show_header_menu(self, pos):
menu = QMenu(self) menu = QMenu(self)
visible = [f for f in self.model_.fields if f != INDEX_FIELD] 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 = menu.addAction(label)
action.setCheckable(True) action.setCheckable(True)
action.setChecked(field in visible) action.setChecked(field in visible)
@@ -754,7 +834,7 @@ class TrackTableView(QTableView):
if chosen.isChecked(): if chosen.isChecked():
new_visible = visible + [field] new_visible = visible + [field]
# Preserve canonical column ordering # 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: else:
new_visible = [f for f in visible if f != field] new_visible = [f for f in visible if f != field]
if not new_visible: if not new_visible:
@@ -766,6 +846,9 @@ class TrackTableView(QTableView):
def _show_context_menu(self, pos): def _show_context_menu(self, pos):
if not self.selected_track_ids(): if not self.selected_track_ids():
return return
if self.read_only:
self._show_read_only_menu(pos)
return
menu = QMenu(self) menu = QMenu(self)
info_action = menu.addAction("Get Info\tCtrl+I") info_action = menu.addAction("Get Info\tCtrl+I")
copy_action = menu.addAction("Copy\tCtrl+C") copy_action = menu.addAction("Copy\tCtrl+C")
@@ -850,6 +933,17 @@ class TrackTableView(QTableView):
elif chosen is delete_action: elif chosen is delete_action:
self.delete_from_library_requested.emit(self.selected_track_ids()) 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 ---- # ---- rating hover / click ----
def _is_rating_index(self, index) -> bool: def _is_rating_index(self, index) -> bool:
@@ -877,16 +971,27 @@ class TrackTableView(QTableView):
def mouseMoveEvent(self, event): def mouseMoveEvent(self, event):
index = self.indexAt(event.position().toPoint()) index = self.indexAt(event.position().toPoint())
self._set_rating_hover(index if self._is_rating_index(index) else None) 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) super().mouseMoveEvent(event)
def leaveEvent(self, event): def leaveEvent(self, event):
self._set_rating_hover(None) self._set_rating_hover(None)
self._set_cassette_hover(None)
super().leaveEvent(event) super().leaveEvent(event)
def mousePressEvent(self, event): def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton: if event.button() == Qt.MouseButton.LeftButton:
pos = event.position().toPoint() pos = event.position().toPoint()
index = self.indexAt(pos) 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): if self._is_rating_index(index):
track = self.model_.track_at(self.model_.source_row(index.row())) track = self.model_.track_at(self.model_.source_row(index.row()))
slot = rating_slot_at( slot = rating_slot_at(
@@ -900,6 +1005,8 @@ class TrackTableView(QTableView):
# ---- playback ---- # ---- playback ----
def _on_double_click(self, index): def _on_double_click(self, index):
if self.read_only:
return # browsing only: the audio isn't here
ids = self.view_order_track_ids() ids = self.view_order_track_ids()
if ids: if ids:
self.play_requested.emit(ids, index.row()) self.play_requested.emit(ids, index.row())
+298
View File
@@ -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()