From 87d43f8e0c7e4aa54780c02bbb29b8c5f7e25e11 Mon Sep 17 00:00:00 2001 From: trav Date: Mon, 28 Sep 2026 21:40:59 -0700 Subject: [PATCH] =?UTF-8?q?v0.31.0:=20Cassette=20followed=20playlists=20?= =?UTF-8?q?=E2=80=94=20a=20friend's=20playlist=20in=20your=20sidebar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 9 + lintunes/__init__.py | 2 +- lintunes/cassette/delivery.py | 18 ++ lintunes/cassette/service.py | 25 ++- lintunes/gui/cassette_delivery.py | 5 +- lintunes/gui/cassette_ui.py | 222 +++++++++++++++++++++++ lintunes/gui/followed_view.py | 119 +++++++++++++ lintunes/gui/friend_mode.py | 3 +- lintunes/gui/main_window.py | 23 ++- lintunes/gui/sidebar.py | 84 ++++++++- lintunes/gui/sync_settings_dialog.py | 34 +++- lintunes/player.py | 33 +++- tests/test_round68.py | 4 +- tests/test_round69.py | 252 +++++++++++++++++++++++++++ 14 files changed, 809 insertions(+), 24 deletions(-) create mode 100644 lintunes/gui/followed_view.py create mode 100644 tests/test_round69.py diff --git a/CLAUDE.md b/CLAUDE.md index 18ac0c4..38635cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -505,6 +505,15 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal already in my library is never imported twice, which is what makes a crash between import and requests-rewrite harmless; a missing `library.json` prunes nothing. + **Followed playlists** (a UFO in friend mode) are never `Playlist`s of mine: + `PlaylistTree.followed_provider` injects them (mixed alphabetically or in a + friend folder, per `Friend.layout`), and `gui/followed_view.py` plays them + from `friends//cache/`. Player queue entries may be **string keys** + (`cassette::`) resolved by `Player.track_resolver`; a key + scrobbles (`track_finished`) but never calls `record_play`. Requests are + always computed by `service._write_requests` (cassetted ∪ followed songs not + yet cached), so a save never re-asks for what's already in the cache, and a + pass prunes cache files no followed playlist uses. - **`lintunes/cast/`** — Chromecast playback (Connections menu), using the **media-receiver model**: `server.py` runs a `ThreadingHTTPServer` on an diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 264d54c..67a2df6 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.30.0" +__version__ = "0.31.0" diff --git a/lintunes/cassette/delivery.py b/lintunes/cassette/delivery.py index 43547fc..9fb1d9d 100644 --- a/lintunes/cassette/delivery.py +++ b/lintunes/cassette/delivery.py @@ -131,6 +131,9 @@ class ReceivePlan: requests_after: set = field(default_factory=set) requests_path: Path = None already_have: list = field(default_factory=list) # ids skipped: exact match + # Cached songs no followed playlist uses any more (unfollowed, or the + # friend took them out): deleted to give the space back. + cache_prune: list = field(default_factory=list) # Paths def followed_needed(friend_library, friend, cache: Path) -> set[int]: @@ -191,6 +194,16 @@ def plan_receive(friend, root: Path, my_index: matching.LibraryIndex, continue plan.arrivals.append(Arrival(tid, path, wanted_lib, wanted_cache)) + playlists_file = source / share.PLAYLISTS_FILE + if playlists_file.exists() and publish.read_json(playlists_file) is not None: + keep = set() + for pid in friend.followed: + playlist = their.playlist(pid) + if playlist is not None: + keep.update(playlist.track_ids) + plan.cache_prune = [p for p in _cache_files(cache) + if int(p.stem) not in keep] + arrived_lib = {a.track_id for a in plan.arrivals if a.to_library} arrived_cache = {a.track_id for a in plan.arrivals if a.to_cache} plan.cassetted = cassetted - arrived_lib @@ -247,6 +260,11 @@ def run_plans(send_plans, receive_plans, music_dir: Path | None, part.unlink(missing_ok=True) result.errors.append((f"Sending {source.name}", str(exc))) for plan in receive_plans: + for path in plan.cache_prune: + try: + path.unlink(missing_ok=True) + except OSError as exc: + result.errors.append((f"Tidying the cache ({path.name})", str(exc))) for arrival in plan.arrivals: if cancel is not None and cancel.is_set(): return result diff --git a/lintunes/cassette/service.py b/lintunes/cassette/service.py index 1b7f3ea..84e7be6 100644 --- a/lintunes/cassette/service.py +++ b/lintunes/cassette/service.py @@ -239,11 +239,11 @@ class CassetteService(QObject): # ---- requests ---- - def save_requests(self, token: str, cassetted, followed, requested): - """Friend mode's Save and Close: remember what I cassetted and which - playlists I follow, and tell the friend (``requests.json`` in my - folder to them) which of their track ids to send.""" - from lintunes.cassette import requests as req + def save_requests(self, token: str, cassetted, followed): + """Friend mode's Save and Close (and a cassette pressed in a followed + playlist): remember what I cassetted and which playlists I follow, + and tell the friend (``requests.json`` in my folder to them) which of + their songs to send.""" with self._lock: friend = self._state.friends.get(token) if friend is None: @@ -251,9 +251,22 @@ class CassetteService(QObject): friend.cassetted = sorted(int(t) for t in cassetted) friend.followed = sorted(followed) self._save() - req.write_requests(out_dir(self.root, token), requested) + self._write_requests(friend) self.changed.emit() + def _write_requests(self, friend): + """requests.json = cassetted ∪ followed songs not in the cache yet.""" + from lintunes.cassette import requests as req + from lintunes.cassette.delivery import followed_needed + from lintunes.cassette.friend_library import load_friend_library + from lintunes.cassette.state import cache_dir + wanted = set(friend.cassetted) + if friend.followed: + their = load_friend_library(in_dir(self.root, friend.token)) + wanted |= followed_needed(their, friend, + cache_dir(self.root, friend.token)) + req.write_requests(out_dir(self.root, friend.token), wanted) + def apply_delivery(self, token: str, cassetted, requested): """After a batch arrived: what's still wanted, and the requests file that says so (rewritten only if it changed).""" diff --git a/lintunes/gui/cassette_delivery.py b/lintunes/gui/cassette_delivery.py index 00e33b3..b0ddc3d 100644 --- a/lintunes/gui/cassette_delivery.py +++ b/lintunes/gui/cassette_delivery.py @@ -24,6 +24,7 @@ class DeliveryCoordinator(QObject): notice = pyqtSignal(str) step_failed = pyqtSignal(str, str) library_grew = pyqtSignal(list) # new track ids + passed = pyqtSignal() # a pass finished (the cache may have moved) def __init__(self, service, manager, parent=None): super().__init__(parent) @@ -96,9 +97,10 @@ class DeliveryCoordinator(QObject): friend, root, index, is_complete=lambda path, t=token: self._service.is_complete(t, path))) if not any(p.copies or p.deletes for p in sends) and \ - not any(p.arrivals for p in receives): + not any(p.arrivals or p.cache_prune for p in receives): # Nothing to move, but prunes and exact-match skips still count. self._finish_receives(receives, set()) + self.passed.emit() return self._worker.start(sends, receives, self._manager.organize_root()) @@ -125,6 +127,7 @@ class DeliveryCoordinator(QObject): f"{names.get(token, 'a friend')}.") for what, error in result.errors[:3]: self.step_failed.emit(what, error) + self.passed.emit() if self._again: self._again = False self._debounce.start() diff --git a/lintunes/gui/cassette_ui.py b/lintunes/gui/cassette_ui.py index 300f774..5fce4e6 100644 --- a/lintunes/gui/cassette_ui.py +++ b/lintunes/gui/cassette_ui.py @@ -38,6 +38,9 @@ class CassetteUi(QObject): self.service = None self.friend_mode = None # FriendMode while browsing a friend self.delivery = None # DeliveryCoordinator (host machine only) + self.followed_view = None # FollowedPlaylistView, made on first use + self._followed_snapshot = None + self._friend_libraries: dict = {} # token -> (mtime key, FriendLibrary) # ---- the service (host machine only) ---- @@ -54,6 +57,7 @@ class CassetteUi(QObject): from lintunes.cassette.service import CassetteService self.service = CassetteService(parent=self) self.service.changed.connect(self._refresh_selector) + self.service.changed.connect(self._refresh_followed) self.service.notice.connect(self._on_notice) self.service.step_failed.connect(self._on_step_failed) self.service.friends_added.connect(self._republish_timer.start) @@ -78,7 +82,9 @@ class CassetteUi(QObject): self.delivery.step_failed.connect(self._on_step_failed) self.delivery.library_grew.connect(self._on_library_grew) self.service.friends_added.connect(self.delivery.schedule) + self.delivery.passed.connect(self._on_delivery_pass) self.delivery.start() + self._hook_window() self._refresh_selector() return self.service @@ -158,6 +164,222 @@ class CassetteUi(QObject): return True return self.friend_mode.confirm_leave() + # ---- followed playlists (my sidebar) ---- + + def _hook_window(self): + """Connect the sidebar tree and the player to followed playlists.""" + window = self._window + sidebar = getattr(window, "sidebar", None) + if sidebar is None or getattr(self, "_hooked", False): + return + self._hooked = True + tree = sidebar.tree + tree.followed_provider = self.followed_entries + tree.followed_selected.connect(self.show_followed) + tree.unfollow_requested.connect(self.unfollow) + player = getattr(window, "player", None) + if player is not None: + player.track_resolver = self.resolve_key + player.track_changed.connect(lambda _t: self._update_followed_icon()) + player.playing_changed.connect(lambda _p: self._update_followed_icon()) + self._refresh_followed() + + def friend_library(self, token: str): + """Their shared library, re-read only when their files changed.""" + from lintunes.cassette.friend_library import load_friend_library + from lintunes.cassette.state import in_dir + folder = in_dir(self.service.root, token) + stamp = [] + for name in ("library.json", "playlists.json", "cassette.json"): + try: + stat = (folder / name).stat() + stamp.append((stat.st_mtime_ns, stat.st_size)) + except OSError: + stamp.append(None) + cached = self._friend_libraries.get(token) + if cached is not None and cached[0] == stamp: + return cached[1] + friend = self.service.state().friends.get(token) + library = load_friend_library(folder, friend.name if friend else "") + self._friend_libraries[token] = (stamp, library) + return library + + def followed_entries(self) -> list[dict]: + if self.service is None: + return [] + entries = [] + for token, friend in self.service.state().friends.items(): + if not friend.followed: + continue + library = self.friend_library(token) + for pid in friend.followed: + playlist = library.playlist(pid) + entries.append({ + "key": f"cassette:{token}:{pid}", + "name": playlist.name if playlist else "(no longer shared)", + "token": token, "friend": friend.name, + "layout": friend.layout}) + return entries + + def _refresh_followed(self): + """Rebuild the sidebar tree only when what it shows moved.""" + if not getattr(self, "_hooked", False): + return + snapshot = self.followed_entries() + if snapshot != self._followed_snapshot: + self._followed_snapshot = snapshot + self._window.sidebar.tree.rebuild() + + def cached_ids(self, token: str) -> set[int]: + from lintunes.cassette.delivery import _cache_files + from lintunes.cassette.state import cache_dir + return {int(p.stem) for p in _cache_files(cache_dir(self.service.root, token))} + + def _cache_path(self, token: str, track_id: int): + from lintunes.cassette.delivery import _cache_files + from lintunes.cassette.state import cache_dir + return next((p for p in _cache_files(cache_dir(self.service.root, token)) + if p.stem == str(track_id)), None) + + def followed_contents(self, token: str, pid: str): + """(their playlist or None, its Tracks — cached ones pointing at the + cache file — and the friend's name).""" + import copy + library = self.friend_library(token) + friend = self.service.state().friends.get(token) + playlist = library.playlist(pid) + tracks = [] + for tid in (playlist.track_ids if playlist else []): + track = library.tracks.get(tid) + if track is None: + continue + track = copy.copy(track) + cached = self._cache_path(token, tid) + track.location = str(cached) if cached else "" + tracks.append(track) + return playlist, tracks, friend.name if friend else "your friend" + + def resolve_key(self, key: str): + """Player's lookup for a friend track key.""" + from lintunes.gui.followed_view import parse_key + import copy + parsed = parse_key(key) + if parsed is None or self.service is None: + return None + token, tid = parsed + track = self.friend_library(token).tracks.get(tid) + cached = self._cache_path(token, tid) + if track is None or cached is None: + return None + track = copy.copy(track) + track.location = str(cached) + return track + + def show_followed(self, key: str): + from lintunes.gui.followed_view import FollowedPlaylistView + window = self._window + if self.followed_view is None: + self.followed_view = FollowedPlaylistView(self) + window.content_stack.addWidget(self.followed_view) + self.followed_view.play_requested.connect(window.play_keys) + self.followed_view.table.tracks_changed.connect(window.update_totals) + self.followed_view.show_followed(key) + window.content_stack.setCurrentWidget(self.followed_view) + self._update_followed_icon() + window.update_totals() + + def _update_followed_icon(self): + view = self.followed_view + if view is None: + return + player = self._window.player + context = getattr(self._window, "now_playing_context", "") + key = player.current_key if context == view.context_id() else None + view.set_now_playing(key, player.is_playing()) + + def _on_delivery_pass(self): + self._refresh_followed() + if self.followed_view is not None and self.followed_view.isVisible(): + self.followed_view.refresh() + + def cassette_state(self, token: str, track_id: int): + """A followed song's cassette button: None when I already have it + (exact match), on when asked for, off otherwise.""" + friend = self.service.state().friends.get(token) + if friend is None: + return None + if track_id in friend.cassetted: + return "on" + track = self.friend_library(token).tracks.get(track_id) + if track is not None and self._manager is not None: + from lintunes.cassette import matching + index = matching.LibraryIndex(self._manager.library.tracks.values()) + if index.classify(track) == matching.EXACT: + return None + return "off" + + def toggle_cassette(self, token: str, track_id: int): + """Pressed in a followed playlist: take it into my library. Already + in the cache → imported right now; otherwise asked for.""" + friend = self.service.state().friends.get(token) + if friend is None: + return + cassetted = set(friend.cassetted) + if track_id in cassetted: + cassetted.discard(track_id) + else: + cached = self._cache_path(token, track_id) + if cached is not None and self._import_cached(cached): + self.say("Added to your library.") + if self.followed_view is not None: + self.followed_view.table.viewport().update() + return + cassetted.add(track_id) + self.service.save_requests(token, cassetted, friend.followed) + if self.delivery is not None: + self.delivery.schedule() + + def _import_cached(self, path) -> bool: + from lintunes.importers.file_importer import stage_file, track_from_file + music_dir = self._manager.organize_root() if self._manager else None + if music_dir is None: + return False + try: + dest, fields = stage_file(path, music_dir) + except OSError as exc: + self._on_step_failed("Adding the song to your library", str(exc)) + return False + track = track_from_file(dest, fields, path.stem) + self._manager.add_track(track) + self._on_library_grew([track.track_id]) + return True + + def unfollow(self, key: str): + parts = key.split(":") + if len(parts) != 3: + return + token, pid = parts[1], parts[2] + friend = self.service.state().friends.get(token) + if friend is None: + return + followed = [p for p in friend.followed if p != pid] + self.service.save_requests(token, friend.cassetted, followed) + if self.followed_view is not None and self.followed_view.key == key: + self._window.show_my_library() + self._refresh_followed() + if self.delivery is not None: + self.delivery.schedule() # prunes the cache + + def last_synced_text(self, token: str) -> str: + from lintunes.gui.sync_settings_dialog import format_when + friend = self.service.state().friends.get(token) if self.service else None + if friend is None: + return "" + return f"{friend.name}'s folder last synced {format_when(friend.last_synced)}" + + def say(self, message: str): + self._window.statusBar().showMessage(message, NOTICE_MS) + def _on_library_grew(self, track_ids): refresh = getattr(self._window, "refresh_library_view", None) if refresh is not None: diff --git a/lintunes/gui/followed_view.py b/lintunes/gui/followed_view.py new file mode 100644 index 0000000..19c4bee --- /dev/null +++ b/lintunes/gui/followed_view.py @@ -0,0 +1,119 @@ +"""A friend's playlist I follow, in my own sidebar, playing from the cache. + +Their songs, not mine: the track ids are theirs, so nothing here drags into +my playlists, edits, or copies. What it does do is **play** — a song whose +file has arrived in ``cache/`` plays like any other, queued by an opaque key +(``cassette::``) that Player resolves through Cassette; a +song still on its way is greyed and skipped. + +The cassette column is hidden by default (right-click the header to show +it); pressing one asks for that song for my library, the same as in friend +mode, and says so at once — there's no Save step here. +""" +from __future__ import annotations + +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtGui import QColor +from PyQt6.QtWidgets import QLabel, QVBoxLayout, QWidget + +from lintunes.gui.track_table import TrackTableView +from lintunes.models.playlist import PlaylistSettings + +NOT_HERE_GREY = QColor("#a0a0a0") +FOLLOWED_COLUMNS = ["name", "total_time", "artist", "album", "genre", "year"] + + +def track_key(token: str, track_id: int) -> str: + return f"cassette:{token}:{track_id}" + + +def parse_key(key: str) -> tuple[str, int] | None: + parts = key.split(":") if isinstance(key, str) else [] + if len(parts) == 3 and parts[0] == "cassette" and parts[2].isdigit(): + return parts[1], int(parts[2]) + return None + + +class FollowedPlaylistView(QWidget): + play_requested = pyqtSignal(list, int, str) # keys, start, context + + def __init__(self, cassette, parent=None): + super().__init__(parent) + self._cassette = cassette # CassetteUi: cache paths, requests + self.key = "" # "cassette::" + self.token = "" + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + self.title = QLabel() + font = self.title.font() + font.setBold(True) + self.title.setFont(font) + self.title.setContentsMargins(8, 2, 8, 2) + layout.addWidget(self.title) + self.table = TrackTableView(playlist_mode=True) + self.table.set_read_only(True) + self.table.set_content_editable(False) + self._settings = PlaylistSettings(visible_columns=list(FOLLOWED_COLUMNS), + sort_column="#") + self.table.apply_settings(self._settings) + self.table.columns_changed.connect(self._on_columns) + self.table.model_.foreground_for = self._foreground + self.table.cassette_hooks = (self._cassette_state, self._cassette_toggle) + self.table.doubleClicked.connect(self._on_double_click) + layout.addWidget(self.table) + self._cached: set[int] = set() + self._friend_name = "" + + def context_id(self) -> str: + return f"followed:{self.key}" + + def show_followed(self, key: str): + """Load (or reload) the followed playlist ``key``.""" + self.key = key + parts = key.split(":") + self.token, pid = parts[1], parts[2] + playlist, tracks, friend_name = self._cassette.followed_contents(self.token, pid) + self._friend_name = friend_name + self._cached = self._cassette.cached_ids(self.token) + name = playlist.name if playlist is not None else "(no longer shared)" + self.title.setText(f"{name} · {friend_name}'s playlist") + self.table.set_tracks(tracks) + + def refresh(self): + if self.key: + self.show_followed(self.key) + + def status_suffix(self) -> str: + """For the bottom bar: when the friend's folder last synced.""" + return self._cassette.last_synced_text(self.token) + + def _on_columns(self, columns): + self._settings.visible_columns = columns + self.table.apply_settings(self._settings) + + def _foreground(self, track): + return None if track.track_id in self._cached else NOT_HERE_GREY + + def _cassette_state(self, track_id: int): + return self._cassette.cassette_state(self.token, track_id) + + def _cassette_toggle(self, track_id: int): + self._cassette.toggle_cassette(self.token, track_id) + + def _on_double_click(self, index): + tracks = [self.table.model_.track_at(self.table.model_.source_row(row)) + for row in range(self.table.model_.rowCount())] + clicked = tracks[index.row()] + playable = [t for t in tracks if t.track_id in self._cached] + if clicked.track_id not in self._cached: + self._cassette.say(f"“{clicked.name}” hasn't arrived from " + f"{self._friend_name} yet.") + return + keys = [track_key(self.token, t.track_id) for t in playable] + self.play_requested.emit(keys, playable.index(clicked), self.context_id()) + + def set_now_playing(self, key, playing: bool): + parsed = parse_key(key) if key else None + track_id = parsed[1] if parsed and parsed[0] == self.token else None + self.table.model_.set_now_playing(track_id, playing) diff --git a/lintunes/gui/friend_mode.py b/lintunes/gui/friend_mode.py index e3501bd..34e3a6c 100644 --- a/lintunes/gui/friend_mode.py +++ b/lintunes/gui/friend_mode.py @@ -333,8 +333,7 @@ class FriendMode(QObject): QMessageBox.StandardButton.Cancel) if answer != QMessageBox.StandardButton.Save: return False - self._service.save_requests(self.token, self.cassetted, self.followed, - self.wanted_ids()) + self._service.save_requests(self.token, self.cassetted, self.followed) self._leave() return True diff --git a/lintunes/gui/main_window.py b/lintunes/gui/main_window.py index 6eb6bf6..59e72c8 100644 --- a/lintunes/gui/main_window.py +++ b/lintunes/gui/main_window.py @@ -279,12 +279,15 @@ class MainWindow(QMainWindow): def _update_totals(self): view = self._content.currentWidget() count, total_ms = view.table.total_stats() + text = "" if count: noun = "track" if count == 1 else "tracks" - self._totals_label.setText( - f"{count:,} {noun} · {format_total_time(total_ms)}") - else: - self._totals_label.setText("") + text = f"{count:,} {noun} · {format_total_time(total_ms)}" + # A friend's followed playlist also says when their folder last synced. + suffix = getattr(view, "status_suffix", None) + if suffix is not None and suffix(): + text = f"{text} · {suffix()}" if text else suffix() + self._totals_label.setText(text) # ---- preferences / theming ---- @@ -403,6 +406,18 @@ class MainWindow(QMainWindow): def sidebar(self): return self._sidebar + def play_keys(self, keys, start, context): + """Play a followed friend playlist (queue of friend-track keys).""" + self._now_playing_context = context + self.player.play_queue(keys, start) + + @property + def now_playing_context(self) -> str: + return self._now_playing_context + + def update_totals(self): + self._update_totals() + def refresh_library_view(self, _track_ids=None): """Songs arrived from somewhere other than an import dialog (a friend's delivery): show them.""" diff --git a/lintunes/gui/sidebar.py b/lintunes/gui/sidebar.py index 7187876..edd53f5 100644 --- a/lintunes/gui/sidebar.py +++ b/lintunes/gui/sidebar.py @@ -74,6 +74,8 @@ class SidebarPanel(QWidget): self.library_button.clicked.connect(self._on_library_clicked) self.tree.playlist_selected.connect(self._on_playlist_selected) + self.tree.followed_selected.connect( + lambda _key: self.library_button.setChecked(False)) self.friend_button.clicked.connect(self.friend_selector_clicked) self.friend_list.playlist_selected.connect(self._on_friend_playlist) self.friend_list.follow_toggled.connect(self.follow_toggled) @@ -316,10 +318,17 @@ class PlaylistTree(QTreeWidget): playlist_selected = pyqtSignal(str) # persistent_id export_requested = pyqtSignal(str) # persistent_id + # A friend's playlist I follow (key "cassette::"). + followed_selected = pyqtSignal(str) + unfollow_requested = pyqtSignal(str) def __init__(self, manager, parent=None): super().__init__(parent) self._manager = manager + # Friend sharing: Callable[[], list[dict]] of followed playlists, each + # {key, name, token, friend, layout}. Never Playlists — they aren't + # mine, so they stay out of the library and its conflict merge. + self.followed_provider = None self._drop_target: QTreeWidgetItem | None = None # KIND_ROLE value a drop during the active drag would land on # ("folder" for internal playlist moves, "playlist" for track drops). @@ -391,7 +400,29 @@ class PlaylistTree(QTreeWidget): add_level(item, playlist.persistent_id) item.setExpanded(playlist.persistent_id in expanded) - add_level(None, "") + followed = self.followed_provider() if self.followed_provider else [] + mixed = [f for f in followed if f["layout"] != "folder"] + foldered: dict[str, list] = {} + for entry in followed: + if entry["layout"] == "folder": + foldered.setdefault(entry["token"], []).append(entry) + if not mixed and not foldered: + add_level(None, "") + else: + # One alphabetical top level: my playlists and folders, followed + # playlists shown mixed in, and a folder per grouped friend. + mine = {p.persistent_id: p for p in children.get("", [])} + rows = [(p.name.lower(), "mine", pid) for pid, p in mine.items()] + rows += [(f["name"].lower(), "followed", f) for f in mixed] + rows += [(entries[0]["friend"].lower(), "friend", entries) + for entries in foldered.values()] + for _key, kind, value in sorted(rows, key=lambda r: (r[0], r[1])): + if kind == "mine": + self._add_mine(value, children, expanded, add_level) + elif kind == "followed": + self.addTopLevelItem(self._followed_item(value)) + else: + self.addTopLevelItem(self._friend_folder(value, expanded)) self.blockSignals(False) if selected_pid: @@ -399,13 +430,52 @@ class PlaylistTree(QTreeWidget): if item: self.setCurrentItem(item) + def _add_mine(self, pid, children, expanded, add_level): + """One of my top-level playlists/folders (and a folder's contents), + when the top level is being interleaved with followed ones.""" + playlist = self._manager.library.playlists[pid] + is_folder = playlist.playlist_type == PlaylistType.FOLDER + item = QTreeWidgetItem([playlist.name]) + item.setData(0, PID_ROLE, pid) + item.setData(0, KIND_ROLE, "folder" if is_folder else + "smart" if playlist.is_smart else "playlist") + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable) + self.addTopLevelItem(item) + if is_folder: + add_level(item, pid) + item.setExpanded(pid in expanded) + + def _followed_item(self, entry) -> QTreeWidgetItem: + from lintunes.gui.cassette_art import ufo_icon + item = QTreeWidgetItem([entry["name"]]) + item.setData(0, PID_ROLE, entry["key"]) + item.setData(0, KIND_ROLE, "followed") + item.setIcon(0, ufo_icon()) + item.setToolTip(0, f"{entry['friend']}'s playlist") + item.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable) + return item + + def _friend_folder(self, entries, expanded) -> QTreeWidgetItem: + from lintunes.gui.cassette_art import ufo_icon + token = entries[0]["token"] + folder = QTreeWidgetItem([entries[0]["friend"]]) + folder.setData(0, PID_ROLE, f"cassette-folder:{token}") + folder.setData(0, KIND_ROLE, "friend-folder") + folder.setIcon(0, ufo_icon()) + folder.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable) + for entry in sorted(entries, key=lambda e: e["name"].lower()): + folder.addChild(self._followed_item(entry)) + folder.setExpanded(f"cassette-folder:{token}" in expanded) + return folder + def _expanded_pids(self) -> set[str]: """Persistent ids of folders currently expanded in the tree.""" result: set[str] = set() stack = [self.topLevelItem(i) for i in range(self.topLevelItemCount())] while stack: item = stack.pop() - if item.data(0, KIND_ROLE) == "folder" and item.isExpanded(): + if item.data(0, KIND_ROLE) in ("folder", "friend-folder") \ + and item.isExpanded(): result.add(item.data(0, PID_ROLE)) stack.extend(item.child(i) for i in range(item.childCount())) return result @@ -497,6 +567,8 @@ class PlaylistTree(QTreeWidget): return if current.data(0, KIND_ROLE) in ("playlist", "smart"): self.playlist_selected.emit(current.data(0, PID_ROLE)) + elif current.data(0, KIND_ROLE) == "followed": + self.followed_selected.emit(current.data(0, PID_ROLE)) def edit(self, index, trigger=QAbstractItemView.EditTrigger.AllEditTriggers, event=None): @@ -528,6 +600,14 @@ class PlaylistTree(QTreeWidget): item = self.itemAt(pos) kind = item.data(0, KIND_ROLE) if item else "" pid = item.data(0, PID_ROLE) if item else "" + if kind == "followed": + menu = QMenu(self) + unfollow = menu.addAction("Unfollow") + if menu.exec(self.viewport().mapToGlobal(pos)) is unfollow: + self.unfollow_requested.emit(pid) + return + if kind == "friend-folder": + return # New items land inside the folder under the cursor (or at top level) parent_pid = "" if kind == "folder": diff --git a/lintunes/gui/sync_settings_dialog.py b/lintunes/gui/sync_settings_dialog.py index 3aad890..722a519 100644 --- a/lintunes/gui/sync_settings_dialog.py +++ b/lintunes/gui/sync_settings_dialog.py @@ -13,7 +13,7 @@ from pathlib import Path from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtGui import QImage, QPixmap from PyQt6.QtWidgets import ( - QCheckBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox, + QButtonGroup, QCheckBox, QDialog, QRadioButton, QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QMessageBox, QPushButton, QSplitter, QStackedWidget, QVBoxLayout, QWidget, ) @@ -21,7 +21,9 @@ from PyQt6.QtWidgets import ( from lintunes.cassette import probe as probe_mod from lintunes.cassette import publish from lintunes.cassette import syncthing_api as st -from lintunes.cassette.state import OPEN, in_dir +from lintunes.cassette.state import ( + FOLLOW_LIBRARY, FOLLOW_SEPARATE, LAYOUT_FOLDER, LAYOUT_MIXED, OPEN, in_dir, +) AVATAR_PX = 256 AVATAR_PREVIEW_PX = 72 @@ -296,6 +298,28 @@ class FriendPage(QWidget): box_layout.addWidget(self.details) layout.addWidget(box) + follow = QGroupBox(f"Playlists you follow from {friend.name}") + follow_layout = QVBoxLayout(follow) + self.layout_mixed = QRadioButton("Mix them in with my playlists") + self.layout_folder = QRadioButton(f"Put them in a folder called “{friend.name}”") + self.mode_separate = QRadioButton( + "Keep their songs separate from my library (they still play)") + self.mode_library = QRadioButton("Add their songs to my library") + for first, second in ((self.layout_mixed, self.layout_folder), + (self.mode_separate, self.mode_library)): + group = QButtonGroup(self) + group.addButton(first) + group.addButton(second) + self.layout_folder.setChecked(friend.layout == LAYOUT_FOLDER) + self.layout_mixed.setChecked(friend.layout != LAYOUT_FOLDER) + self.mode_library.setChecked(friend.followed_mode == FOLLOW_LIBRARY) + self.mode_separate.setChecked(friend.followed_mode != FOLLOW_LIBRARY) + for button in (self.layout_mixed, self.layout_folder, + self.mode_separate, self.mode_library): + follow_layout.addWidget(button) + button.toggled.connect(self._on_follow_settings) + layout.addWidget(follow) + self.using_general = QLabel("Using general settings — see General.") self.using_general.setVisible(False) layout.addWidget(self.using_general) @@ -314,6 +338,12 @@ class FriendPage(QWidget): layout.addLayout(row) self.show_status(None) + def _on_follow_settings(self, _checked=False): + self.friend.layout = (LAYOUT_FOLDER if self.layout_folder.isChecked() + else LAYOUT_MIXED) + self.friend.followed_mode = (FOLLOW_LIBRARY if self.mode_library.isChecked() + else FOLLOW_SEPARATE) + def ensure_selector(self, library, same_for_all: bool): if self.selector is None and library is not None: from lintunes.gui.share_selector import ShareSelector diff --git a/lintunes/player.py b/lintunes/player.py index 25b880d..67fe403 100644 --- a/lintunes/player.py +++ b/lintunes/player.py @@ -418,6 +418,13 @@ class Player(QObject): # here rather than in a sink so both playback paths honor it. self._stop_at_ms = 0 self._counted_finish_id: int | None = None + # Queue entries are my track ids (int) or, for a friend's followed + # playlist, opaque string keys ("cassette::") + # resolved through this callback — Player stays manager-free about + # friends. Plays of friend tracks scrobble but don't count in my + # journal. + self.track_resolver = None # Callable[[str], Track | None] + self._current_key = None self._volume = 1.0 # logical 0..1; what the volume slider shows self._local = LocalSink(self) @@ -624,6 +631,7 @@ class Player(QObject): def stop(self): self._sink.stop() self._current_track = None + self._current_key = None self.track_changed.emit(None) def next(self): @@ -668,7 +676,8 @@ class Player(QObject): AAC). The skip is a loop, not recursion: shuffling into a run of unsupported tracks must not walk the stack down.""" while 0 <= self._index < len(self._queue): - track = self._manager.library.tracks.get(self._queue[self._index]) + key = self._queue[self._index] + track = self._resolve(key) if track is None: # The queue references a track that's no longer in the library; # there's nothing to locate, so just report and stop. @@ -700,6 +709,7 @@ class Player(QObject): continue self._current_track = track + self._current_key = key # Arm the custom stop time for this track. It's honored only when # it falls before the end (otherwise the natural finish handles it). if track.stop_time > 0 and (track.total_time == 0 @@ -754,8 +764,23 @@ class Player(QObject): def _note_finished(self, track): """Record a completed play once, guarding against a double count if both the stop-time and end-of-media paths fire for the same track.""" - if track is None or track.track_id == self._counted_finish_id: + if track is None: return - self._counted_finish_id = track.track_id - self._manager.record_play(track.track_id) + key = (self._current_key if track is self._current_track + and self._current_key is not None else track.track_id) + if key == self._counted_finish_id: + return + self._counted_finish_id = key + if not isinstance(key, str): + self._manager.record_play(track.track_id) self.track_finished.emit(track) + + def _resolve(self, key): + if isinstance(key, str): + return self.track_resolver(key) if self.track_resolver else None + return self._manager.library.tracks.get(key) + + @property + def current_key(self): + """The queue entry playing: a track id, or a friend track's key.""" + return self._current_key diff --git a/tests/test_round68.py b/tests/test_round68.py index 82c045f..54e190c 100644 --- a/tests/test_round68.py +++ b/tests/test_round68.py @@ -236,7 +236,7 @@ def test_loop_between_two_libraries(qapp, tmp_path, mp3_file): _sync(alice.root, bob.root, token) # Bob cassettes Alice's song and saves. - bob.save_requests(token, {5}, [], {5}) + bob.save_requests(token, {5}, []) _sync(alice.root, bob.root, token) def run(coordinator): @@ -326,7 +326,7 @@ def test_real_delivery(qapp, tmp_path, syncthing_pair, mp3_file): edited.friends[token].selection = Selection(all_library=True) a.apply_settings(edited) assert settle(lambda: 5 in load_their_ids(b, token)) - b.save_requests(token, {5}, [], {5}) + b.save_requests(token, {5}, []) assert settle(lambda: len(bob_mgr.library.tracks) == 1), "never arrived" assert settle(lambda: not any((out_dir(a.root, token) / "outbox").glob("*"))), \ "sender's outbox never cleaned up" diff --git a/tests/test_round69.py b/tests/test_round69.py new file mode 100644 index 0000000..5466348 --- /dev/null +++ b/tests/test_round69.py @@ -0,0 +1,252 @@ +"""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::") 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")