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
+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()