- 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.
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
|