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:
@@ -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()
|
||||
Reference in New Issue
Block a user