Files
lintunes/tests/test_round69.py
T
travandClaude Opus 5.5 87d43f8e0c v0.31.0: Cassette followed playlists — a friend's playlist in your sidebar
- A followed playlist (the UFO in friend mode) appears in my sidebar with
  a purple UFO: mixed in with my playlists alphabetically, or inside a
  folder named after the friend (per-friend, in Sync Settings). It's never
  a Playlist of mine, so it stays out of the library and its merges.
- It plays from the cache. Player's queue accepts friend-track keys,
  resolved through Cassette: they scrobble to Last.fm but don't count in
  my play journal. Songs still on their way are grey and skipped.
- The cassette column there is hidden by default (right-click the
  header); pressing one takes the song into my library — straight from
  the cache if it's there, otherwise by asking for it.
- Sync Settings per friend: keep followed songs separate (default) or add
  them to my library. Unfollowing (right-click) frees their cache.
- The bottom bar says when the friend's folder last synced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:40:59 -07:00

253 lines
11 KiB
Python

"""Round 69: followed playlists in my sidebar.
* A followed friend playlist shows in my sidebar with a purple UFO — mixed in
alphabetically, or inside a folder named after the friend (a per-friend
setting) — and is never a Playlist of mine, so it stays out of the
library and its conflict merge.
* It plays from the cache: Player queues opaque keys
("cassette:<token>:<their id>") resolved through Cassette. Plays scrobble
but don't count in my play journal. Songs not here yet are grey and skipped.
* Its cassette column is hidden by default; pressing one takes the song into
my library — straight from the cache if it's there, otherwise by asking.
* Unfollowing frees the cache; requests never re-ask for cached songs.
"""
import shutil
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from lintunes.cassette import delivery, matching, publish, share
from lintunes.cassette import requests as req
from lintunes.cassette.service import CassetteService
from lintunes.cassette.state import (
CassetteState, Friend, Selection, cache_dir, in_dir, out_dir,
)
from lintunes.gui.followed_view import parse_key, track_key
from lintunes.models import Playlist, Track
from lintunes.models.library import Library
TOKEN = "0123456789abcdef"
def _friend_share(root, mp3_file, cached=(10,)):
theirs = Library()
for tid, name in ((10, "One"), (11, "Two"), (12, "Three")):
theirs.tracks[tid] = Track(track_id=tid, name=name, artist="Sam's Band",
album="Demo", total_time=1000, size=100)
theirs.playlists["P"] = Playlist(name="Road Trip", persistent_id="P",
track_ids=[11, 10])
share.publish_share(in_dir(root, TOKEN), theirs,
Selection(all_library=True, all_playlists=True))
publish.publish_intro(in_dir(root, TOKEN), "Sam", None)
cache = cache_dir(root, TOKEN)
cache.mkdir(parents=True, exist_ok=True)
for tid in cached:
shutil.copyfile(mp3_file, cache / f"{tid}.mp3")
return theirs
# ---- keys and the player ----
class TestPlayer:
def test_keys(self):
assert parse_key(track_key(TOKEN, 12)) == (TOKEN, 12)
assert parse_key("12") is None and parse_key(12) is None
def test_friend_track_scrobbles_but_isnt_counted(self, qapp, tmp_path, mp3_file):
from lintunes.player import Player
manager = MagicMock()
player = Player(manager)
friend_track = Track(track_id=5, name="Theirs", location=str(mp3_file))
player.track_resolver = lambda key: friend_track if key == "cassette:t:5" else None
finished = []
player.track_finished.connect(finished.append)
player._current_track = friend_track
player._current_key = "cassette:t:5"
player._note_finished(friend_track)
player._note_finished(friend_track)
manager.record_play.assert_not_called()
assert finished == [friend_track] # Last.fm still hears it
assert player._resolve("cassette:t:5") is friend_track
assert player._resolve("cassette:t:6") is None
# ---- the service never re-asks for cached songs ----
class TestRequests:
def test_followed_songs_already_cached_arent_requested(self, qapp, tmp_path, mp3_file):
root = tmp_path / "root"
_friend_share(root, mp3_file, cached=(10,))
service = CassetteService(root=root, config_loader=lambda: {})
service._state.friends[TOKEN] = Friend(token=TOKEN, device_id="D", name="Sam")
service.save_requests(TOKEN, {12}, ["P"])
assert req.read_requests(out_dir(root, TOKEN)) == {11, 12}
def test_unfollowed_cache_is_pruned(self, tmp_path, mp3_file):
root = tmp_path / "root"
_friend_share(root, mp3_file, cached=(10, 12))
friend = Friend(token=TOKEN, device_id="D", name="Sam", followed=["P"])
plan = delivery.plan_receive(friend, root, matching.LibraryIndex([]))
assert [p.name for p in plan.cache_prune] == ["12.mp3"]
friend.followed = []
plan = delivery.plan_receive(friend, root, matching.LibraryIndex([]))
assert sorted(p.name for p in plan.cache_prune) == ["10.mp3", "12.mp3"]
delivery.run_plans([], [plan], None)
assert list(cache_dir(root, TOKEN).iterdir()) == []
def test_no_playlists_file_prunes_nothing(self, tmp_path, mp3_file):
root = tmp_path / "root"
_friend_share(root, mp3_file, cached=(10,))
(in_dir(root, TOKEN) / share.PLAYLISTS_FILE).unlink()
friend = Friend(token=TOKEN, device_id="D", name="Sam")
plan = delivery.plan_receive(friend, root, matching.LibraryIndex([]))
assert plan.cache_prune == []
# ---- the sidebar and the view ----
class _Window:
def __init__(self, qapp):
from PyQt6.QtWidgets import QMainWindow, QStackedWidget
from lintunes.gui.sidebar import SidebarPanel
self.main = QMainWindow()
self.content_stack = QStackedWidget()
self.main.setCentralWidget(self.content_stack)
manager = MagicMock()
manager.library.playlists = {
"M1": Playlist(name="Morning", persistent_id="M1"),
"M2": Playlist(name="Zebra", persistent_id="M2")}
self.sidebar = SidebarPanel(manager, MagicMock())
self.player = MagicMock()
self.player.current_key = None
self.player.is_playing.return_value = False
self.now_playing_context = ""
self.played = []
self.totals = 0
def statusBar(self):
return self.main.statusBar()
def play_keys(self, keys, start, context):
self.played.append((keys, start, context))
def update_totals(self):
self.totals += 1
def show_my_library(self):
pass
@pytest.fixture
def followed(qapp, tmp_path, mp3_file):
from lintunes.gui.cassette_ui import CassetteUi
from lintunes.library_manager import LibraryManager
root = tmp_path / "root"
_friend_share(root, mp3_file, cached=(10,))
service = CassetteService(root=root, config_loader=lambda: {})
service._state.friends[TOKEN] = Friend(token=TOKEN, device_id="D", name="Sam",
followed=["P"], layout="mixed")
manager = LibraryManager(Library(), tmp_path / "data")
manager.organize_root = lambda: tmp_path / "Music"
window = _Window(qapp)
ui = CassetteUi(window.main, MagicMock(), manager)
ui._window = window
ui.service = service
ui._hook_window()
return ui, window, service, manager
class TestSidebar:
def test_mixed_in_alphabetically(self, followed):
ui, window, _, _ = followed
tree = window.sidebar.tree
names = [tree.topLevelItem(i).text(0) for i in range(tree.topLevelItemCount())]
assert names == ["Morning", "Road Trip", "Zebra"]
def test_in_a_friend_folder(self, followed):
ui, window, service, _ = followed
service._state.friends[TOKEN].layout = "folder"
ui._refresh_followed()
tree = window.sidebar.tree
names = [tree.topLevelItem(i).text(0) for i in range(tree.topLevelItemCount())]
assert names == ["Morning", "Sam", "Zebra"]
folder = tree.topLevelItem(1)
assert folder.child(0).text(0) == "Road Trip"
def test_followed_items_arent_editable_or_draggable(self, followed):
from PyQt6.QtCore import Qt
_, window, _, _ = followed
tree = window.sidebar.tree
item = tree._find_item(f"cassette:{TOKEN}:P")
assert not item.flags() & Qt.ItemFlag.ItemIsEditable
assert not item.flags() & Qt.ItemFlag.ItemIsDragEnabled
class TestFollowedView:
def test_cached_plays_uncached_is_grey(self, followed):
ui, window, _, _ = followed
ui.show_followed(f"cassette:{TOKEN}:P")
view = ui.followed_view
assert [t.track_id for t in view.table.model_.tracks] == [11, 10]
assert view._foreground(view.table.model_.tracks[0]) is not None # 11 not here
assert view._foreground(view.table.model_.tracks[1]) is None # 10 cached
view._on_double_click(view.table.model_.index(1, 0))
assert window.played == [([track_key(TOKEN, 10)], 0,
f"followed:cassette:{TOKEN}:P")]
view._on_double_click(view.table.model_.index(0, 0)) # not here yet
assert len(window.played) == 1
assert "last synced" in view.status_suffix()
def test_cassette_column_hidden_by_default(self, followed):
from lintunes.gui.track_table import CASSETTE_FIELD
ui, _, _, _ = followed
ui.show_followed(f"cassette:{TOKEN}:P")
assert CASSETTE_FIELD not in ui.followed_view.table.model_.fields
assert ui.followed_view.table.cassette_hooks is not None
def test_resolve_points_at_the_cache(self, followed):
ui, _, service, _ = followed
track = ui.resolve_key(track_key(TOKEN, 10))
assert Path(track.location).parent == cache_dir(service.root, TOKEN)
assert ui.resolve_key(track_key(TOKEN, 11)) is None
def test_cassette_a_cached_song_imports_it_now(self, followed, tmp_path):
ui, _, service, manager = followed
ui.toggle_cassette(TOKEN, 10)
[track] = manager.library.tracks.values()
assert tmp_path / "Music" in Path(track.location).parents
assert track.play_count == 0
assert service.state().friends[TOKEN].cassetted == []
def test_cassette_an_uncached_song_asks_for_it(self, followed):
ui, _, service, manager = followed
ui.toggle_cassette(TOKEN, 12)
assert service.state().friends[TOKEN].cassetted == [12]
assert 12 in req.read_requests(out_dir(service.root, TOKEN))
assert ui.cassette_state(TOKEN, 12) == "on"
ui.toggle_cassette(TOKEN, 12)
assert ui.cassette_state(TOKEN, 12) == "off"
def test_unfollow(self, followed):
ui, window, service, _ = followed
ui.unfollow(f"cassette:{TOKEN}:P")
assert service.state().friends[TOKEN].followed == []
tree = window.sidebar.tree
names = [tree.topLevelItem(i).text(0) for i in range(tree.topLevelItemCount())]
assert names == ["Morning", "Zebra"]
class TestSettings:
def test_follow_settings_on_the_friend_page(self, qapp, tmp_path):
from lintunes.gui.sync_settings_dialog import SyncSettingsDialog
from tests.test_round64 import _NoProbe
state = CassetteState()
state.friends["t"] = Friend(token="t", device_id="D", name="Sam")
dialog = SyncSettingsDialog(state, tmp_path, {}, probe=_NoProbe())
page = dialog.friend_pages["t"]
assert page.layout_folder.isChecked() and page.mode_separate.isChecked()
page.layout_mixed.setChecked(True)
page.mode_library.setChecked(True)
friend = dialog.state.friends["t"]
assert (friend.layout, friend.followed_mode) == ("mixed", "library")