v0.28.0: Cassette share selector — choose what friends can cassette

- Sync Settings gets a share selector on each friend page (and on General
  when "Share the same selection with all friends" is on; each friend's
  own selection is kept, greyed, and comes back when it's turned off).
- Library: a genre/artist/album column browser with a checkbox beside
  every artist, album and track, search, Select All / Select None and
  "Share whole library". Playlists: a checklist with "Share all
  playlists". Nothing is shared by default.
- Checking an artist or album is a snapshot of its tracks; the whole
  library and shared playlists are live. A shared playlist's tracks are
  checked and locked, with a tooltip naming the playlist(s).
- cassette/share.py writes the trimmed copy (library.json, playlists.json)
  into each friend's folder: tags yes, listening history and local paths
  no. Written on OK and after library edits (debounced), and only when the
  bytes would change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 21:18:14 -07:00
co-authored by Claude Opus 5.5
parent f04e69f9b2
commit 759fa85153
9 changed files with 906 additions and 12 deletions
+245
View File
@@ -0,0 +1,245 @@
"""Round 66: the share selector and the trimmed library copy a friend reads.
* Nothing is shared by default. "Share whole library" is live (new songs are
offered too); checked artists/albums store the track ids they covered then,
so a new album shows up unchecked; shared playlists are live and their
tracks are locked in the Library section with a tooltip naming them.
* Offering never sends anything — the copy only lists what may be asked for,
and keeps the tags while dropping my listening history and anything local.
* The copy is written only when its bytes would change.
"""
import json
from PyQt6.QtCore import Qt
from lintunes.cassette import share
from lintunes.cassette.state import CassetteState, Friend, Selection, out_dir
from lintunes.models import Playlist, PlaylistType, Track
from lintunes.models.library import Library
def _library():
library = Library()
rows = [
(1, "Reel One", "Analog Ghost", "Tape Hiss", "Folk"),
(2, "Reel Two", "Analog Ghost", "Tape Hiss", "Jazz"),
(3, "B-Side", "Analog Ghost", "Other Album", "Jazz"),
(4, "Étoile", "Marguerite Vaux", "Nuit Blanche", "Jazz"),
(5, "Café", "Marguerite Vaux", "Nuit Blanche", "Electronic"),
]
for tid, name, artist, album, genre in rows:
library.tracks[tid] = Track(
track_id=tid, name=name, artist=artist, album=album, genre=genre,
year=1987, total_time=180000, size=4_000_000, play_count=41,
rating=80, loved=True, comments="private note",
location=f"/music/{tid}.mp3", date_added="2020-01-01T00:00:00",
play_date_utc="2026-01-01T00:00:00")
library.playlists["P1"] = Playlist(name="Road Trip", persistent_id="P1",
track_ids=[4, 1])
library.playlists["P2"] = Playlist(name="Morning", persistent_id="P2",
track_ids=[5])
library.playlists["F"] = Playlist(name="Boxes", persistent_id="F",
playlist_type=PlaylistType.FOLDER)
library.playlists["S"] = Playlist(name="Music", persistent_id="S",
is_system=True,
playlist_type=PlaylistType.SYSTEM)
return library
# ---- what is offered ----
class TestOffered:
def test_nothing_by_default(self):
assert share.offered_ids(_library(), Selection()) == set()
def test_whole_library_is_live(self):
library = _library()
selection = Selection(all_library=True)
library.tracks[9] = Track(track_id=9, name="New")
assert 9 in share.offered_ids(library, selection)
def test_checked_tracks_plus_shared_playlists(self):
selection = Selection(track_ids={2, 99}, playlist_ids={"P1"})
assert share.offered_ids(_library(), selection) == {1, 2, 4}
def test_shared_playlist_is_live(self):
library = _library()
selection = Selection(playlist_ids={"P1"})
library.playlists["P1"].track_ids.append(3)
assert 3 in share.offered_ids(library, selection)
def test_locks_name_every_playlist(self):
library = _library()
library.playlists["P2"].track_ids.append(4)
locks = share.locks(library, Selection(all_playlists=True))
assert sorted(locks[4]) == ["Morning", "Road Trip"]
def test_folders_and_system_playlists_are_never_shared(self):
names = [p.name for p in share.shared_playlists(
_library(), Selection(all_playlists=True))]
assert names == ["Morning", "Road Trip"]
# ---- what the copy holds ----
class TestCopy:
def test_tags_yes_history_no(self):
library = _library()
entry = share.build_library(library, Selection(track_ids={1}))["tracks"][0]
assert entry["id"] == 1 and entry["name"] == "Reel One"
assert entry["artist"] == "Analog Ghost" and entry["year"] == 1987
assert entry["size"] == 4_000_000 and entry["total_time"] == 180000
for private in ("play_count", "rating", "loved", "comments", "location",
"date_added", "play_date_utc", "persistent_id"):
assert private not in entry
def test_playlists_keep_order_and_drop_unoffered(self):
library = _library()
library.playlists["P1"].track_ids.append(77) # dangling id
data = share.build_playlists(library, Selection(playlist_ids={"P1"}))
assert data["playlists"] == [{"id": "P1", "name": "Road Trip",
"smart": False, "track_ids": [4, 1]}]
def test_written_only_when_changed(self, tmp_path):
library = _library()
selection = Selection(track_ids={1})
assert share.publish_share(tmp_path, library, selection)
mtime = (tmp_path / share.LIBRARY_FILE).stat().st_mtime_ns
assert not share.publish_share(tmp_path, library, selection)
assert (tmp_path / share.LIBRARY_FILE).stat().st_mtime_ns == mtime
selection.track_ids.add(2)
assert share.publish_share(tmp_path, library, selection)
data = json.loads((tmp_path / share.LIBRARY_FILE).read_text())
assert [t["id"] for t in data["tracks"]] == [1, 2]
# ---- the service ----
class TestRepublish:
def test_each_friend_gets_their_selection(self, qapp, tmp_path):
from lintunes.cassette.service import CassetteService
library = _library()
service = CassetteService(root=tmp_path, config_loader=lambda: {})
service.set_library_provider(lambda: library)
service._state.friends["a"] = Friend(token="a", device_id="A", name="Amy",
selection=Selection(track_ids={1}))
service._state.friends["b"] = Friend(token="b", device_id="B", name="Bo",
selection=Selection(playlist_ids={"P2"}))
assert sorted(service.republish()) == ["a", "b"]
assert service.republish() == []
def ids(token):
data = json.loads((out_dir(tmp_path, token) / share.LIBRARY_FILE).read_text())
return [t["id"] for t in data["tracks"]]
assert ids("a") == [1]
assert ids("b") == [5]
service._state.share_same_for_all = True
service._state.general = Selection(track_ids={3})
service.republish()
assert ids("a") == ids("b") == [3]
# ---- the selector ----
def _selector(qapp, selection=None, library=None):
from lintunes.gui.share_selector import ShareSelector
return ShareSelector(library or _library(), selection or Selection())
def _track_items(selector):
return {selector.tracks.topLevelItem(i).data(0, Qt.ItemDataRole.UserRole):
selector.tracks.topLevelItem(i)
for i in range(selector.tracks.topLevelItemCount())}
def _row(column, text):
return column.findItems(text, Qt.MatchFlag.MatchExactly)[0]
class TestSelector:
def test_starts_empty(self, qapp):
selector = _selector(qapp)
assert all(item.checkState(0) == Qt.CheckState.Unchecked
for item in _track_items(selector).values())
def test_checking_an_artist_checks_its_tracks(self, qapp):
selection = Selection()
selector = _selector(qapp, selection)
_row(selector.artists, "Analog Ghost").setCheckState(Qt.CheckState.Checked)
assert selection.track_ids == {1, 2, 3}
assert _row(selector.albums, "Tape Hiss").checkState() == Qt.CheckState.Checked
def test_album_then_partial_artist(self, qapp):
selection = Selection()
selector = _selector(qapp, selection)
_row(selector.albums, "Tape Hiss").setCheckState(Qt.CheckState.Checked)
assert selection.track_ids == {1, 2}
assert _row(selector.artists, "Analog Ghost").checkState() == \
Qt.CheckState.PartiallyChecked
def test_genre_filter_scopes_the_artist_box(self, qapp):
selection = Selection()
selector = _selector(qapp, selection)
selector.genres.setCurrentItem(_row(selector.genres, "Jazz"))
_row(selector.artists, "Analog Ghost").setCheckState(Qt.CheckState.Checked)
assert selection.track_ids == {2, 3} # not the Folk one
def test_playlist_tracks_are_locked_with_a_tooltip(self, qapp):
selection = Selection()
selector = _selector(qapp, selection)
_row(selector.playlists, "Road Trip").setCheckState(Qt.CheckState.Checked)
assert selection.playlist_ids == {"P1"}
item = _track_items(selector)[4]
assert item.checkState(0) == Qt.CheckState.Checked
assert not item.flags() & Qt.ItemFlag.ItemIsUserCheckable
assert "Road Trip" in item.toolTip(0)
assert 4 not in selection.track_ids # locked, not chosen
def test_select_none_keeps_locked_tracks(self, qapp):
selection = Selection(track_ids={2, 3}, playlist_ids={"P1"})
selector = _selector(qapp, selection)
selector.lib_none.click()
assert selection.track_ids == set()
assert _track_items(selector)[1].checkState(0) == Qt.CheckState.Checked
def test_select_all_buttons(self, qapp):
selection = Selection()
selector = _selector(qapp, selection)
selector.lib_all.click()
selector.pl_all.click()
assert selection.track_ids == {1, 2, 3, 4, 5}
assert selection.playlist_ids == {"P1", "P2"}
def test_whole_library_greys_the_browser(self, qapp):
selection = Selection()
selector = _selector(qapp, selection)
selector.whole_library.setChecked(True)
assert selection.all_library
assert not selector._browser_host.isEnabled()
assert all(i.checkState(0) == Qt.CheckState.Checked
for i in _track_items(selector).values())
# ---- the dialog ----
class TestSameForAll:
def test_turning_it_off_restores_each_friend(self, qapp, tmp_path):
from lintunes.gui.sync_settings_dialog import SyncSettingsDialog
from tests.test_round64 import _NoProbe
state = CassetteState()
state.friends["a"] = Friend(token="a", device_id="A", name="Amy",
selection=Selection(track_ids={1}))
dialog = SyncSettingsDialog(state, tmp_path, {}, probe=_NoProbe(),
library=_library())
dialog._show_page(1)
page = dialog.friend_pages["a"]
dialog.general.same_for_all.setChecked(True)
assert dialog.general.selector is not None
assert page.using_general.isVisibleTo(dialog)
assert not page.selector.whole_library.isEnabled()
dialog.general.selector.lib_all.click()
dialog.general.same_for_all.setChecked(False)
assert dialog.state.friends["a"].selection.track_ids == {1}
assert dialog.state.general.track_ids == {1, 2, 3, 4, 5}
assert not dialog.state.share_same_for_all