- 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>
246 lines
11 KiB
Python
246 lines
11 KiB
Python
"""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
|