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